diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..663a4a5
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,12 @@
+.env
+servers.yml
+data/
+__pycache__/
+.git/
+*.pyc
+/data
+/logs
+/build
+/dist
+/.git
+/.github
\ No newline at end of file
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
new file mode 100644
index 0000000..8f1606f
--- /dev/null
+++ b/.github/workflows/docker-build.yml
@@ -0,0 +1,43 @@
+name: Docker Build & Push (condor)
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+ push:
+ branches:
+ - main
+
+jobs:
+ docker-build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Determine Docker tag
+ id: vars
+ run: |
+ if [[ "${{ github.event_name }}" == "push" ]]; then
+ echo "TAG=latest" >> $GITHUB_ENV
+ else
+ echo "TAG=development" >> $GITHUB_ENV
+ fi
+
+ - name: Build and push Docker image (multi-arch)
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./Dockerfile
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: hummingbot/condor:${{ env.TAG }}
diff --git a/README.md b/README.md
index c7581ac..7fa6343 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,10 @@ A Telegram bot for monitoring and trading with Hummingbot via the Backend API.
**Prerequisites:** Python 3.11+, Conda, Hummingbot Backend API running, Telegram Bot Token
```bash
-# Install
+# clone repo
+git clone https://github.com/hummingbot/condor.git
+cd condor
+# environment setup
conda env create -f environment.yml
conda activate condor
diff --git a/docker-compose.yml b/docker-compose.yml
index da9ece4..54482b1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,7 +7,7 @@ services:
- .env
volumes:
# Persist bot data (user preferences, trading context, etc.)
- - ./condor_bot_data.pickle:/app/condor_bot_data.pickle
+ - ./data:/app/data
# Mount servers config
- ./servers.yml:/app/servers.yml
environment:
diff --git a/handlers/bots/__init__.py b/handlers/bots/__init__.py
index b537953..33bf833 100644
--- a/handlers/bots/__init__.py
+++ b/handlers/bots/__init__.py
@@ -28,11 +28,14 @@
show_controller_detail,
handle_stop_controller,
handle_confirm_stop_controller,
+ handle_quick_stop_controller,
+ handle_quick_start_controller,
handle_stop_bot,
handle_confirm_stop_bot,
show_bot_logs,
handle_back_to_bot,
handle_refresh_bot,
+ handle_refresh_controller,
# Controller chart & edit
show_controller_chart,
show_controller_edit,
@@ -44,7 +47,27 @@
show_controller_configs_menu,
show_configs_list,
handle_configs_page,
+ # Unified configs menu with multi-select
+ show_configs_by_type,
+ show_type_selector,
+ handle_cfg_toggle,
+ handle_cfg_page,
+ handle_cfg_clear_selection,
+ handle_cfg_delete_confirm,
+ handle_cfg_delete_execute,
+ handle_cfg_deploy,
+ # Edit loop
+ handle_cfg_edit_loop,
+ show_cfg_edit_form,
+ handle_cfg_edit_field,
+ process_cfg_edit_input,
+ handle_cfg_edit_prev,
+ handle_cfg_edit_next,
+ handle_cfg_edit_save,
+ handle_cfg_edit_save_all,
+ handle_cfg_edit_cancel,
show_new_grid_strike_form,
+ show_new_pmm_mister_form,
show_config_form,
handle_set_field,
handle_toggle_side,
@@ -86,6 +109,11 @@
handle_gs_wizard_amount,
handle_gs_accept_prices,
handle_gs_back_to_prices,
+ handle_gs_back_to_connector,
+ handle_gs_back_to_pair,
+ handle_gs_back_to_side,
+ handle_gs_back_to_leverage,
+ handle_gs_back_to_amount,
handle_gs_interval_change,
handle_gs_wizard_take_profit,
handle_gs_edit_id,
@@ -100,6 +128,21 @@
handle_gs_review_back,
handle_gs_edit_price,
process_gs_wizard_input,
+ # PMM Mister wizard
+ handle_pmm_wizard_connector,
+ handle_pmm_wizard_pair,
+ handle_pmm_wizard_leverage,
+ handle_pmm_wizard_allocation,
+ handle_pmm_wizard_spreads,
+ handle_pmm_wizard_tp,
+ handle_pmm_save,
+ handle_pmm_review_back,
+ handle_pmm_edit_id,
+ handle_pmm_edit_field,
+ handle_pmm_set_field,
+ handle_pmm_edit_advanced,
+ handle_pmm_adv_setting,
+ process_pmm_wizard_input,
)
from ._shared import clear_bots_state, SIDE_LONG, SIDE_SHORT
@@ -133,12 +176,13 @@ async def bots_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
# Check if specific bot name was provided
if update.message and context.args and len(context.args) > 0:
bot_name = context.args[0]
+ chat_id = update.effective_chat.id
# For direct command with bot name, show detail view
from utils.telegram_formatters import format_bot_status, format_error_message
from ._shared import get_bots_client
try:
- client = await get_bots_client()
+ client = await get_bots_client(chat_id)
bot_status = await client.bot_orchestration.get_bot_status(bot_name)
response_message = format_bot_status(bot_status)
await msg.reply_text(response_message, parse_mode="MarkdownV2")
@@ -192,9 +236,73 @@ async def bots_callback_handler(update: Update, context: ContextTypes.DEFAULT_TY
elif main_action == "list_configs":
await show_configs_list(update, context)
+ # Unified configs menu with multi-select
+ elif main_action == "cfg_select_type":
+ await show_type_selector(update, context)
+
+ elif main_action == "cfg_type":
+ if len(action_parts) > 1:
+ controller_type = action_parts[1]
+ await show_configs_by_type(update, context, controller_type)
+
+ elif main_action == "cfg_toggle":
+ if len(action_parts) > 1:
+ config_id = action_parts[1]
+ await handle_cfg_toggle(update, context, config_id)
+
+ elif main_action == "cfg_page":
+ if len(action_parts) > 1:
+ page = int(action_parts[1])
+ await handle_cfg_page(update, context, page)
+
+ elif main_action == "cfg_clear_selection":
+ await handle_cfg_clear_selection(update, context)
+
+ elif main_action == "cfg_deploy":
+ await handle_cfg_deploy(update, context)
+
+ elif main_action == "cfg_delete_confirm":
+ await handle_cfg_delete_confirm(update, context)
+
+ elif main_action == "cfg_delete_execute":
+ await handle_cfg_delete_execute(update, context)
+
+ # Edit loop handlers
+ elif main_action == "cfg_edit_loop":
+ await handle_cfg_edit_loop(update, context)
+
+ elif main_action == "cfg_edit_form":
+ await show_cfg_edit_form(update, context)
+
+ elif main_action == "cfg_edit_field":
+ if len(action_parts) > 1:
+ field_name = action_parts[1]
+ await handle_cfg_edit_field(update, context, field_name)
+
+ elif main_action == "cfg_edit_prev":
+ await handle_cfg_edit_prev(update, context)
+
+ elif main_action == "cfg_edit_next":
+ await handle_cfg_edit_next(update, context)
+
+ elif main_action == "cfg_edit_save":
+ await handle_cfg_edit_save(update, context)
+
+ elif main_action == "cfg_edit_save_all":
+ await handle_cfg_edit_save_all(update, context)
+
+ elif main_action == "cfg_edit_cancel":
+ await handle_cfg_edit_cancel(update, context)
+
+ elif main_action == "noop":
+ pass # Do nothing - used for pagination display button
+
elif main_action == "new_grid_strike":
await show_new_grid_strike_form(update, context)
+ elif main_action == "new_pmm_mister":
+ await show_new_pmm_mister_form(update, context)
+
elif main_action == "edit_config":
if len(action_parts) > 1:
config_index = int(action_parts[1])
@@ -327,6 +435,21 @@ async def bots_callback_handler(update: Update, context: ContextTypes.DEFAULT_TY
elif main_action == "gs_back_to_prices":
await handle_gs_back_to_prices(update, context)
+ elif main_action == "gs_back_to_connector":
+ await handle_gs_back_to_connector(update, context)
+
+ elif main_action == "gs_back_to_pair":
+ await handle_gs_back_to_pair(update, context)
+
+ elif main_action == "gs_back_to_side":
+ await handle_gs_back_to_side(update, context)
+
+ elif main_action == "gs_back_to_leverage":
+ await handle_gs_back_to_leverage(update, context)
+
+ elif main_action == "gs_back_to_amount":
+ await handle_gs_back_to_amount(update, context)
+
elif main_action == "gs_interval":
if len(action_parts) > 1:
interval = action_parts[1]
@@ -372,6 +495,65 @@ async def bots_callback_handler(update: Update, context: ContextTypes.DEFAULT_TY
elif main_action == "gs_review_back":
await handle_gs_review_back(update, context)
+ # PMM Mister wizard
+ elif main_action == "pmm_connector":
+ if len(action_parts) > 1:
+ connector = action_parts[1]
+ await handle_pmm_wizard_connector(update, context, connector)
+
+ elif main_action == "pmm_pair":
+ if len(action_parts) > 1:
+ pair = action_parts[1]
+ await handle_pmm_wizard_pair(update, context, pair)
+
+ elif main_action == "pmm_leverage":
+ if len(action_parts) > 1:
+ leverage = int(action_parts[1])
+ await handle_pmm_wizard_leverage(update, context, leverage)
+
+ elif main_action == "pmm_alloc":
+ if len(action_parts) > 1:
+ allocation = float(action_parts[1])
+ await handle_pmm_wizard_allocation(update, context, allocation)
+
+ elif main_action == "pmm_spreads":
+ if len(action_parts) > 1:
+ spreads = action_parts[1]
+ await handle_pmm_wizard_spreads(update, context, spreads)
+
+ elif main_action == "pmm_tp":
+ if len(action_parts) > 1:
+ tp = float(action_parts[1])
+ await handle_pmm_wizard_tp(update, context, tp)
+
+ elif main_action == "pmm_save":
+ await handle_pmm_save(update, context)
+
+ elif main_action == "pmm_review_back":
+ await handle_pmm_review_back(update, context)
+
+ elif main_action == "pmm_edit_id":
+ await handle_pmm_edit_id(update, context)
+
+ elif main_action == "pmm_edit":
+ if len(action_parts) > 1:
+ field = action_parts[1]
+ await handle_pmm_edit_field(update, context, field)
+
+ elif main_action == "pmm_set":
+ if len(action_parts) > 2:
+ field = action_parts[1]
+ value = action_parts[2]
+ await handle_pmm_set_field(update, context, field, value)
+
+ elif main_action == "pmm_edit_advanced":
+ await handle_pmm_edit_advanced(update, context)
+
+ elif main_action == "pmm_adv":
+ if len(action_parts) > 1:
+ setting = action_parts[1]
+ await handle_pmm_adv_setting(update, context, setting)
+
# Bot detail
elif main_action == "bot_detail":
if len(action_parts) > 1:
@@ -409,6 +591,17 @@ async def bots_callback_handler(update: Update, context: ContextTypes.DEFAULT_TY
elif main_action == "confirm_stop_ctrl":
await handle_confirm_stop_controller(update, context)
+ # Quick stop/start controller (from bot detail view)
+ elif main_action == "stop_ctrl_quick":
+ if len(action_parts) > 1:
+ idx = int(action_parts[1])
+ await handle_quick_stop_controller(update, context, idx)
+
+ elif main_action == "start_ctrl_quick":
+ if len(action_parts) > 1:
+ idx = int(action_parts[1])
+ await handle_quick_start_controller(update, context, idx)
+
# Stop bot (uses context)
elif main_action == "stop_bot":
await handle_stop_bot(update, context)
@@ -427,6 +620,11 @@ async def bots_callback_handler(update: Update, context: ContextTypes.DEFAULT_TY
elif main_action == "refresh_bot":
await handle_refresh_bot(update, context)
+ elif main_action == "refresh_ctrl":
+ if len(action_parts) > 1:
+ idx = int(action_parts[1])
+ await handle_refresh_controller(update, context, idx)
+
else:
logger.warning(f"Unknown bots action: {action}")
await query.message.reply_text(f"Unknown action: {action}")
@@ -465,7 +663,10 @@ async def bots_message_handler(update: Update, context: ContextTypes.DEFAULT_TYP
# Handle controller config field input
if bots_state.startswith("set_field:"):
await process_field_input(update, context, user_input)
- # Handle live controller field input
+ # Handle live controller bulk edit input
+ elif bots_state == "ctrl_bulk_edit":
+ await process_controller_field_input(update, context, user_input)
+ # Handle live controller field input (legacy single field)
elif bots_state.startswith("ctrl_set:"):
await process_controller_field_input(update, context, user_input)
# Handle deploy field input (legacy form)
@@ -483,6 +684,15 @@ async def bots_message_handler(update: Update, context: ContextTypes.DEFAULT_TYP
# Handle Grid Strike wizard input
elif bots_state == "gs_wizard_input":
await process_gs_wizard_input(update, context, user_input)
+ # Handle PMM Mister wizard input
+ elif bots_state == "pmm_wizard_input":
+ await process_pmm_wizard_input(update, context, user_input)
+ # Handle config edit loop field input (legacy single field)
+ elif bots_state.startswith("cfg_edit_input:"):
+ await process_cfg_edit_input(update, context, user_input)
+ # Handle config bulk edit (key=value format)
+ elif bots_state == "cfg_bulk_edit":
+ await process_cfg_edit_input(update, context, user_input)
else:
logger.debug(f"Unhandled bots state: {bots_state}")
diff --git a/handlers/bots/_shared.py b/handlers/bots/_shared.py
index 376db54..2442349 100644
--- a/handlers/bots/_shared.py
+++ b/handlers/bots/_shared.py
@@ -58,9 +58,12 @@
# SERVER CLIENT HELPER
# ============================================
-async def get_bots_client():
+async def get_bots_client(chat_id: Optional[int] = None):
"""Get the API client for bot operations
+ Args:
+ chat_id: Optional chat ID to get per-chat server. If None, uses global default.
+
Returns:
Client instance with bot_orchestration and controller endpoints
@@ -75,14 +78,18 @@ async def get_bots_client():
if not enabled_servers:
raise ValueError("No enabled API servers available")
- # Use default server if set, otherwise fall back to first enabled
- default_server = server_manager.get_default_server()
+ # Use per-chat server if chat_id provided, otherwise global default
+ if chat_id is not None:
+ default_server = server_manager.get_default_server_for_chat(chat_id)
+ else:
+ default_server = server_manager.get_default_server()
+
if default_server and default_server in enabled_servers:
server_name = default_server
else:
server_name = enabled_servers[0]
- logger.info(f"Bots using server: {server_name}")
+ logger.info(f"Bots using server: {server_name}" + (f" (chat_id={chat_id})" if chat_id else ""))
client = await server_manager.get_client(server_name)
return client
@@ -332,7 +339,7 @@ async def fetch_candles(
connector_name: str,
trading_pair: str,
interval: str = "1m",
- max_records: int = 100
+ max_records: int = 420
) -> Optional[Dict[str, Any]]:
"""Fetch candles data for a trading pair."""
try:
diff --git a/handlers/bots/controller_handlers.py b/handlers/bots/controller_handlers.py
index 2134304..7145e5c 100644
--- a/handlers/bots/controller_handlers.py
+++ b/handlers/bots/controller_handlers.py
@@ -47,6 +47,19 @@
ORDER_TYPE_LIMIT_MAKER,
ORDER_TYPE_LABELS,
)
+from .controllers.grid_strike.grid_analysis import (
+ calculate_natr,
+ calculate_price_stats,
+ suggest_grid_params,
+ generate_theoretical_grid,
+ format_grid_summary,
+)
+from handlers.cex._shared import (
+ fetch_cex_balances,
+ get_cex_balances,
+ fetch_trading_rules,
+ get_trading_rules,
+)
logger = logging.getLogger(__name__)
@@ -56,7 +69,7 @@
# ============================================
# Pagination settings for configs
-CONFIGS_PER_PAGE = 16
+CONFIGS_PER_PAGE = 8 # Reduced to leave space for action buttons
def _get_controller_type_display(controller_name: str) -> tuple[str, str]:
@@ -99,2953 +112,4605 @@ def _format_config_line(cfg: dict, index: int) -> str:
return f"{index}. {display}"
+def _get_config_seq_num(cfg: dict) -> int:
+ """Extract sequence number from config ID for sorting"""
+ config_id = cfg.get("id", "")
+ parts = config_id.split("_", 1)
+ if parts and parts[0].isdigit():
+ return int(parts[0])
+ return -1 # No number goes to end
+
+
+def _get_available_controller_types(configs: list) -> dict[str, int]:
+ """Get available controller types with counts"""
+ type_counts: dict[str, int] = {}
+ for cfg in configs:
+ ctrl_type = cfg.get("controller_name", "unknown")
+ type_counts[ctrl_type] = type_counts.get(ctrl_type, 0) + 1
+ return type_counts
+
+
+def _get_selected_config_ids(context, type_configs: list) -> list[str]:
+ """Get list of selected config IDs from selection state"""
+ selected = context.user_data.get("selected_configs", {}) # {config_id: True}
+ result = []
+ for cfg in type_configs:
+ cfg_id = cfg.get("id", "")
+ if cfg_id and selected.get(cfg_id):
+ result.append(cfg_id)
+ return result
+
+
async def show_controller_configs_menu(update: Update, context: ContextTypes.DEFAULT_TYPE, page: int = 0) -> None:
- """Show the controller configs management menu grouped by type"""
+ """
+ Unified configs menu - shows configs directly with type selector, multi-select,
+ and actions (Deploy, Edit, Delete).
+
+ Selection persists across type/page changes using config IDs (not indices).
+ """
query = update.callback_query
+ chat_id = update.effective_chat.id
try:
- client = await get_bots_client()
+ client = await get_bots_client(chat_id)
configs = await client.controllers.list_controller_configs()
- # Store configs for later use
+ # Store all configs
context.user_data["controller_configs_list"] = configs
+
+ # Get available types
+ type_counts = _get_available_controller_types(configs)
+
+ # Determine current type (default to first available or grid_strike)
+ current_type = context.user_data.get("configs_controller_type")
+ if not current_type or current_type not in type_counts:
+ current_type = list(type_counts.keys())[0] if type_counts else "grid_strike"
+ context.user_data["configs_controller_type"] = current_type
+
+ # Filter and sort configs by current type
+ type_configs = [c for c in configs if c.get("controller_name") == current_type]
+ type_configs.sort(key=_get_config_seq_num, reverse=True)
+ context.user_data["configs_type_filtered"] = type_configs
context.user_data["configs_page"] = page
- total_configs = len(configs)
- total_pages = (total_configs + CONFIGS_PER_PAGE - 1) // CONFIGS_PER_PAGE if total_configs > 0 else 1
+ # Get selection state (uses config IDs for persistence)
+ # Sync with available configs - remove any IDs that no longer exist
+ selected = context.user_data.get("selected_configs", {}) # {config_id: True}
+ available_ids = {c.get("id") for c in configs if c.get("id")}
+ selected = {cfg_id: is_sel for cfg_id, is_sel in selected.items() if cfg_id in available_ids}
+ context.user_data["selected_configs"] = selected
+ selected_ids = [cfg_id for cfg_id, is_sel in selected.items() if is_sel]
- # Calculate page slice
+ # Calculate pagination
+ total_pages = max(1, (len(type_configs) + CONFIGS_PER_PAGE - 1) // CONFIGS_PER_PAGE)
start_idx = page * CONFIGS_PER_PAGE
- end_idx = min(start_idx + CONFIGS_PER_PAGE, total_configs)
- page_configs = configs[start_idx:end_idx]
+ end_idx = min(start_idx + CONFIGS_PER_PAGE, len(type_configs))
+ page_configs = type_configs[start_idx:end_idx]
- # Build message header
+ # Build message
+ type_name, emoji = _get_controller_type_display(current_type)
lines = [r"*Controller Configs*", ""]
- if not configs:
- lines.append(r"_No configurations found\._")
- lines.append(r"Create a new one to get started\!")
- else:
+ # Add separator to maintain consistent width
+ lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
+
+ # Show selected summary (always visible)
+ if selected_ids:
+ lines.append(f"✅ *Selected \\({len(selected_ids)}\\):*")
+ for cfg_id in selected_ids[:5]: # Show max 5
+ lines.append(f" • `{escape_markdown_v2(cfg_id)}`")
+ if len(selected_ids) > 5:
+ lines.append(f" _\\.\\.\\.and {len(selected_ids) - 5} more_")
+ lines.append("")
+
+ # Current type info
+ if type_configs:
if total_pages > 1:
- lines.append(f"_{total_configs} configs \\(page {page + 1}/{total_pages}\\)_")
+ lines.append(f"_{len(type_configs)} {escape_markdown_v2(type_name)} configs \\(page {page + 1}/{total_pages}\\)_")
else:
- lines.append(f"_{total_configs} config{'s' if total_configs != 1 else ''}_")
- lines.append("")
+ lines.append(f"_{len(type_configs)} {escape_markdown_v2(type_name)} config{'s' if len(type_configs) != 1 else ''}_")
+ else:
+ lines.append(f"_No {escape_markdown_v2(type_name)} configs yet_")
- # Group page configs by controller type
- grouped: dict[str, list[tuple[int, dict]]] = {}
- for i, cfg in enumerate(page_configs):
- global_idx = start_idx + i
- ctrl_type = cfg.get("controller_name", "unknown")
- if ctrl_type not in grouped:
- grouped[ctrl_type] = []
- grouped[ctrl_type].append((global_idx, cfg))
-
- # Display each group
- for ctrl_type, type_configs in grouped.items():
- type_name, emoji = _get_controller_type_display(ctrl_type)
- lines.append(f"{emoji} *{escape_markdown_v2(type_name)}*")
- lines.append("```")
- for global_idx, cfg in type_configs:
- line = _format_config_line(cfg, global_idx + 1)
- lines.append(line)
- lines.append("```")
-
- # Build keyboard - numbered buttons (4 per row)
+ # Build keyboard
keyboard = []
- # Config edit buttons for current page
- if page_configs:
- edit_buttons = []
- for i, cfg in enumerate(page_configs):
- global_idx = start_idx + i
- edit_buttons.append(
- InlineKeyboardButton(f"✏️{global_idx + 1}", callback_data=f"bots:edit_config:{global_idx}")
- )
- # Add in rows of 4
- for i in range(0, len(edit_buttons), 4):
- keyboard.append(edit_buttons[i:i+4])
+ # Row 1: Type selector + Create buttons
+ type_row = []
+ # Type selector button (shows current type, click to change)
+ other_types = [t for t in type_counts.keys() if t != current_type]
+ if other_types or len(type_counts) > 1:
+ type_row.append(InlineKeyboardButton(f"{emoji} {type_name} ▼", callback_data="bots:cfg_select_type"))
+ else:
+ type_row.append(InlineKeyboardButton(f"{emoji} {type_name}", callback_data="bots:noop"))
- # Pagination buttons if needed
- if total_pages > 1:
- nav_buttons = []
- if page > 0:
- nav_buttons.append(InlineKeyboardButton("⬅️ Prev", callback_data=f"bots:configs_page:{page - 1}"))
- # Always show Next (loops to first page)
- next_page = (page + 1) % total_pages
- nav_buttons.append(InlineKeyboardButton("Next ➡️", callback_data=f"bots:configs_page:{next_page}"))
- keyboard.append(nav_buttons)
+ # Create button for current type
+ if current_type == "grid_strike":
+ type_row.append(InlineKeyboardButton("➕ New", callback_data="bots:new_grid_strike"))
+ elif "pmm" in current_type.lower():
+ type_row.append(InlineKeyboardButton("➕ New", callback_data="bots:new_pmm_mister"))
+ else:
+ type_row.append(InlineKeyboardButton("➕ New", callback_data="bots:new_grid_strike"))
- keyboard.append([
- InlineKeyboardButton("+ New Grid Strike", callback_data="bots:new_grid_strike"),
- ])
+ keyboard.append(type_row)
+
+ # Config checkboxes - show just the controller name/ID
+ for i, cfg in enumerate(page_configs):
+ config_id = cfg.get("id", f"config_{start_idx + i}")
+ is_selected = selected.get(config_id, False)
+ checkbox = "✅" if is_selected else "⬜"
+
+ # Show just the config ID (truncated if needed)
+ display = f"{checkbox} {config_id[:28]}"
+
+ keyboard.append([
+ InlineKeyboardButton(display, callback_data=f"bots:cfg_toggle:{config_id}")
+ ])
+
+ # Pagination row
+ if total_pages > 1:
+ nav = []
+ if page > 0:
+ nav.append(InlineKeyboardButton("◀️", callback_data=f"bots:cfg_page:{page - 1}"))
+ nav.append(InlineKeyboardButton(f"📄 {page + 1}/{total_pages}", callback_data="bots:noop"))
+ if page < total_pages - 1:
+ nav.append(InlineKeyboardButton("▶️", callback_data=f"bots:cfg_page:{page + 1}"))
+ keyboard.append(nav)
+
+ # Action buttons (only if something selected)
+ if selected_ids:
+ keyboard.append([
+ InlineKeyboardButton(f"🚀 Deploy ({len(selected_ids)})", callback_data="bots:cfg_deploy"),
+ InlineKeyboardButton(f"✏️ Edit ({len(selected_ids)})", callback_data="bots:cfg_edit_loop"),
+ ])
+ keyboard.append([
+ InlineKeyboardButton(f"🗑️ Delete ({len(selected_ids)})", callback_data="bots:cfg_delete_confirm"),
+ InlineKeyboardButton("⬜ Clear", callback_data="bots:cfg_clear_selection"),
+ ])
keyboard.append([
- InlineKeyboardButton("Back", callback_data="bots:main_menu"),
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu"),
])
reply_markup = InlineKeyboardMarkup(keyboard)
+ text_content = "\n".join(lines)
- await query.message.edit_text(
- "\n".join(lines),
- parse_mode="MarkdownV2",
- reply_markup=reply_markup
- )
+ # Handle photo messages (use getattr for FakeMessage compatibility)
+ if getattr(query.message, 'photo', None):
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ await query.message.chat.send_message(
+ text_content,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ else:
+ try:
+ await query.message.edit_text(
+ text_content,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ except BadRequest as e:
+ if "Message is not modified" not in str(e):
+ raise
except Exception as e:
logger.error(f"Error loading controller configs: {e}", exc_info=True)
keyboard = [
- [InlineKeyboardButton("+ New Grid Strike", callback_data="bots:new_grid_strike")],
- [InlineKeyboardButton("Back", callback_data="bots:main_menu")],
+ [InlineKeyboardButton("➕ Grid Strike", callback_data="bots:new_grid_strike")],
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")],
]
error_msg = format_error_message(f"Failed to load configs: {str(e)}")
- await query.message.edit_text(
- error_msg,
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
-
+ try:
+ if getattr(query.message, 'photo', None):
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ await query.message.chat.send_message(
+ error_msg,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ else:
+ await query.message.edit_text(
+ error_msg,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ except Exception:
+ pass
-async def handle_configs_page(update: Update, context: ContextTypes.DEFAULT_TYPE, page: int) -> None:
- """Handle pagination for controller configs menu"""
- await show_controller_configs_menu(update, context, page=page)
+async def show_type_selector(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show type selector popup to switch between controller types"""
+ query = update.callback_query
+ configs = context.user_data.get("controller_configs_list", [])
-# ============================================
-# LIST EXISTING CONFIGS (DEPRECATED - merged into show_controller_configs_menu)
-# ============================================
+ type_counts = _get_available_controller_types(configs)
+ current_type = context.user_data.get("configs_controller_type", "grid_strike")
-async def show_configs_list(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Redirect to controller configs menu (backward compatibility)"""
- await show_controller_configs_menu(update, context)
+ lines = [r"*Select Controller Type*", ""]
+ keyboard = []
+ for ctrl_type, count in sorted(type_counts.items()):
+ type_name, emoji = _get_controller_type_display(ctrl_type)
+ is_current = "• " if ctrl_type == current_type else ""
+ keyboard.append([
+ InlineKeyboardButton(f"{is_current}{emoji} {type_name} ({count})", callback_data=f"bots:cfg_type:{ctrl_type}")
+ ])
-# ============================================
-# PROGRESSIVE GRID STRIKE WIZARD
-# ============================================
+ keyboard.append([
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs"),
+ ])
-async def show_new_grid_strike_form(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Start the progressive Grid Strike wizard - Step 1: Connector"""
- query = update.callback_query
+ await query.message.edit_text(
+ "\n".join(lines),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- # Fetch existing configs for sequence numbering
- try:
- client = await get_bots_client()
- configs = await client.controllers.list_controller_configs()
- context.user_data["controller_configs_list"] = configs
- except Exception as e:
- logger.warning(f"Could not fetch existing configs for sequencing: {e}")
- # Initialize new config with defaults
- config = init_new_controller_config(context, "grid_strike")
- context.user_data["bots_state"] = "gs_wizard"
- context.user_data["gs_wizard_step"] = "connector_name"
- context.user_data["gs_wizard_message_id"] = query.message.message_id
- context.user_data["gs_wizard_chat_id"] = query.message.chat_id
+async def show_configs_by_type(update: Update, context: ContextTypes.DEFAULT_TYPE,
+ controller_type: str, page: int = 0) -> None:
+ """Switch to a specific controller type and show configs"""
+ context.user_data["configs_controller_type"] = controller_type
+ context.user_data["configs_page"] = page
+ await show_controller_configs_menu(update, context, page)
- # Show connector selector directly
- await _show_wizard_connector_step(update, context)
+async def handle_cfg_toggle(update: Update, context: ContextTypes.DEFAULT_TYPE, config_id: str) -> None:
+ """Toggle config selection by config ID"""
+ selected = context.user_data.get("selected_configs", {})
-async def _show_wizard_connector_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Wizard Step 1: Select Connector"""
- query = update.callback_query
- config = get_controller_config(context)
+ if selected.get(config_id):
+ selected.pop(config_id, None)
+ else:
+ selected[config_id] = True
- try:
- client = await get_bots_client()
- cex_connectors = await get_available_cex_connectors(context.user_data, client)
+ context.user_data["selected_configs"] = selected
- if not cex_connectors:
- keyboard = [[InlineKeyboardButton("Back", callback_data="bots:controller_configs")]]
- await query.message.edit_text(
- r"*Grid Strike \- New Config*" + "\n\n"
- r"No CEX connectors configured\." + "\n"
- r"Please configure exchange credentials first\.",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- return
+ page = context.user_data.get("configs_page", 0)
+ await show_controller_configs_menu(update, context, page)
- # Build connector buttons (2 per row)
- keyboard = []
- row = []
- for connector in cex_connectors:
- row.append(InlineKeyboardButton(f"🏦 {connector}", callback_data=f"bots:gs_connector:{connector}"))
- if len(row) == 2:
- keyboard.append(row)
- row = []
- if row:
- keyboard.append(row)
- keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")])
+async def handle_cfg_page(update: Update, context: ContextTypes.DEFAULT_TYPE, page: int) -> None:
+ """Handle pagination for configs"""
+ await show_controller_configs_menu(update, context, page)
- await query.message.edit_text(
- r"*📈 Grid Strike \- New Config*" + "\n\n"
- r"*Step 1/7:* 🏦 Select Connector" + "\n\n"
- r"Choose the exchange for this grid, can be a spot or perpetual exchange:",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- except Exception as e:
- logger.error(f"Error in connector step: {e}", exc_info=True)
- keyboard = [[InlineKeyboardButton("Back", callback_data="bots:controller_configs")]]
- await query.message.edit_text(
- format_error_message(f"Error: {str(e)}"),
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
+async def handle_cfg_clear_selection(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Clear all selected configs"""
+ context.user_data["selected_configs"] = {}
+ page = context.user_data.get("configs_page", 0)
+ await show_controller_configs_menu(update, context, page)
-async def handle_gs_wizard_connector(update: Update, context: ContextTypes.DEFAULT_TYPE, connector: str) -> None:
- """Handle connector selection in wizard"""
+async def handle_cfg_delete_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show delete confirmation dialog"""
query = update.callback_query
- config = get_controller_config(context)
+ selected = context.user_data.get("selected_configs", {})
+ selected_ids = [cfg_id for cfg_id, is_sel in selected.items() if is_sel]
- config["connector_name"] = connector
- set_controller_config(context, config)
+ if not selected_ids:
+ await query.answer("No configs selected", show_alert=True)
+ return
- # Move to trading pair step
- context.user_data["gs_wizard_step"] = "trading_pair"
- await _show_wizard_pair_step(update, context)
+ # Build confirmation message
+ lines = [r"*Delete Configs\?*", ""]
+ lines.append(f"You are about to delete {len(selected_ids)} config{'s' if len(selected_ids) != 1 else ''}:")
+ lines.append("")
+ for cfg_id in selected_ids:
+ lines.append(f"• `{escape_markdown_v2(cfg_id)}`")
-async def handle_gs_wizard_pair(update: Update, context: ContextTypes.DEFAULT_TYPE, pair: str) -> None:
- """Handle trading pair selection from button in wizard"""
- query = update.callback_query
- config = get_controller_config(context)
+ lines.append("")
+ lines.append(r"⚠️ _This action cannot be undone\._")
- config["trading_pair"] = pair.upper()
- set_controller_config(context, config)
+ keyboard = [
+ [
+ InlineKeyboardButton("✅ Yes, Delete", callback_data="bots:cfg_delete_execute"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs"),
+ ]
+ ]
- # Start background fetch of market data
- asyncio.create_task(_background_fetch_market_data(context, config))
+ reply_markup = InlineKeyboardMarkup(keyboard)
+ text_content = "\n".join(lines)
- # Move to side step
- context.user_data["gs_wizard_step"] = "side"
- await _show_wizard_side_step(update, context)
+ await query.message.edit_text(
+ text_content,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
-async def _show_wizard_pair_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Wizard Step 2: Enter Trading Pair"""
+async def handle_cfg_delete_execute(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Execute deletion of selected configs"""
query = update.callback_query
- config = get_controller_config(context)
- connector = config.get("connector_name", "")
- context.user_data["bots_state"] = "gs_wizard_input"
- context.user_data["gs_wizard_step"] = "trading_pair"
-
- # Get recent pairs from existing configs (max 6)
- existing_configs = context.user_data.get("controller_configs_list", [])
- recent_pairs = []
- seen_pairs = set()
- for cfg in reversed(existing_configs): # Most recent first
- pair = cfg.get("trading_pair", "")
- if pair and pair not in seen_pairs:
- seen_pairs.add(pair)
- recent_pairs.append(pair)
- if len(recent_pairs) >= 6:
- break
-
- # Build keyboard with recent pairs (2 per row) + cancel
- keyboard = []
- if recent_pairs:
- row = []
- for pair in recent_pairs:
- row.append(InlineKeyboardButton(pair, callback_data=f"bots:gs_pair:{pair}"))
- if len(row) == 2:
- keyboard.append(row)
- row = []
- if row:
- keyboard.append(row)
+ chat_id = update.effective_chat.id
- keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")])
+ selected = context.user_data.get("selected_configs", {})
+ selected_ids = [cfg_id for cfg_id, is_sel in selected.items() if is_sel]
- recent_hint = ""
- if recent_pairs:
- recent_hint = "\n\nOr type a custom pair below:"
+ if not selected_ids:
+ await query.answer("No configs selected", show_alert=True)
+ return
+ # Show progress
await query.message.edit_text(
- r"*📈 Grid Strike \- New Config*" + "\n\n"
- f"*Connector:* `{escape_markdown_v2(connector)}`" + "\n\n"
- r"*Step 2/7:* 🔗 Trading Pair" + "\n\n"
- r"Select a recent pair or enter a new one:" + escape_markdown_v2(recent_hint),
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
+ f"🗑️ Deleting {len(selected_ids)} config{'s' if len(selected_ids) != 1 else ''}\\.\\.\\.",
+ parse_mode="MarkdownV2"
)
+ # Delete each config
+ client = await get_bots_client(chat_id)
+ deleted = []
+ failed = []
-async def _show_wizard_side_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Wizard Step 3: Select Side"""
- query = update.callback_query
- config = get_controller_config(context)
+ for config_id in selected_ids:
+ try:
+ await client.controllers.delete_controller_config(config_id)
+ deleted.append(config_id)
+ except Exception as e:
+ logger.error(f"Failed to delete config {config_id}: {e}")
+ failed.append((config_id, str(e)))
- connector = config.get("connector_name", "")
- pair = config.get("trading_pair", "")
+ # Clear selection
+ context.user_data["selected_configs"] = {}
- keyboard = [
- [
- InlineKeyboardButton("📈 LONG", callback_data="bots:gs_side:long"),
- InlineKeyboardButton("📉 SHORT", callback_data="bots:gs_side:short"),
- ],
- [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")],
- ]
+ # Build result message
+ lines = []
+ if deleted:
+ lines.append(f"✅ *Deleted {len(deleted)} config{'s' if len(deleted) != 1 else ''}*")
+ for cfg_id in deleted:
+ lines.append(f" • `{escape_markdown_v2(cfg_id)}`")
+
+ if failed:
+ lines.append("")
+ lines.append(f"❌ *Failed to delete {len(failed)}:*")
+ for cfg_id, error in failed:
+ lines.append(f" • `{escape_markdown_v2(cfg_id)}`")
+ lines.append(f" _{escape_markdown_v2(error[:40])}_")
+
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:controller_configs")]]
await query.message.edit_text(
- r"*📈 Grid Strike \- New Config*" + "\n\n"
- f"🏦 *Connector:* `{escape_markdown_v2(connector)}`" + "\n"
- f"🔗 *Pair:* `{escape_markdown_v2(pair)}`" + "\n\n"
- r"*Step 3/7:* 🎯 Side" + "\n\n"
- r"Select trading side:",
+ "\n".join(lines),
parse_mode="MarkdownV2",
reply_markup=InlineKeyboardMarkup(keyboard)
)
-async def handle_gs_wizard_side(update: Update, context: ContextTypes.DEFAULT_TYPE, side_str: str) -> None:
- """Handle side selection in wizard"""
- query = update.callback_query
- config = get_controller_config(context)
+async def handle_cfg_deploy(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Deploy selected configs - bridges to existing deploy flow"""
+ selected = context.user_data.get("selected_configs", {})
+ selected_ids = [cfg_id for cfg_id, is_sel in selected.items() if is_sel]
+ all_configs = context.user_data.get("controller_configs_list", [])
- config["side"] = SIDE_LONG if side_str == "long" else SIDE_SHORT
- set_controller_config(context, config)
+ if not selected_ids:
+ query = update.callback_query
+ await query.answer("No configs selected", show_alert=True)
+ return
- # Move to leverage step
- context.user_data["gs_wizard_step"] = "leverage"
- await _show_wizard_leverage_step(update, context)
+ # Map config IDs to all_configs indices for existing deploy flow
+ deploy_indices = set()
+ for cfg_id in selected_ids:
+ for all_idx, all_cfg in enumerate(all_configs):
+ if all_cfg.get("id") == cfg_id:
+ deploy_indices.add(all_idx)
+ break
+ # Set up for existing deploy flow
+ context.user_data["selected_controllers"] = deploy_indices
-async def _show_wizard_leverage_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Wizard Step 4: Select Leverage"""
- query = update.callback_query
- config = get_controller_config(context)
+ # Don't clear selection - keep it for when user comes back
- connector = config.get("connector_name", "")
- pair = config.get("trading_pair", "")
- side = "📈 LONG" if config.get("side") == SIDE_LONG else "📉 SHORT"
+ # Use existing deploy configure flow
+ await show_deploy_configure(update, context)
- keyboard = [
- [
- InlineKeyboardButton("1x", callback_data="bots:gs_leverage:1"),
- InlineKeyboardButton("5x", callback_data="bots:gs_leverage:5"),
- InlineKeyboardButton("10x", callback_data="bots:gs_leverage:10"),
- ],
- [
- InlineKeyboardButton("20x", callback_data="bots:gs_leverage:20"),
- InlineKeyboardButton("50x", callback_data="bots:gs_leverage:50"),
- InlineKeyboardButton("75x", callback_data="bots:gs_leverage:75"),
- ],
- [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")],
- ]
- await query.message.edit_text(
- r"*📈 Grid Strike \- New Config*" + "\n\n"
- f"🏦 *Connector:* `{escape_markdown_v2(connector)}`" + "\n"
- f"🔗 *Pair:* `{escape_markdown_v2(pair)}`" + "\n"
- f"🎯 *Side:* `{side}`" + "\n\n"
- r"*Step 4/7:* ⚡ Leverage" + "\n\n"
- r"Select leverage:",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
+# ============================================
+# EDIT LOOP - Edit multiple configs in sequence
+# ============================================
+async def handle_cfg_edit_loop(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Start editing selected configs in a loop"""
+ query = update.callback_query
+ selected = context.user_data.get("selected_configs", {})
+ selected_ids = [cfg_id for cfg_id, is_sel in selected.items() if is_sel]
+ all_configs = context.user_data.get("controller_configs_list", [])
-async def handle_gs_wizard_leverage(update: Update, context: ContextTypes.DEFAULT_TYPE, leverage: int) -> None:
- """Handle leverage selection in wizard"""
+ if not selected_ids:
+ await query.answer("No configs selected", show_alert=True)
+ return
+
+ # Build list of configs to edit
+ configs_to_edit = []
+ for cfg_id in selected_ids:
+ for cfg in all_configs:
+ if cfg.get("id") == cfg_id:
+ configs_to_edit.append(cfg.copy())
+ break
+
+ if not configs_to_edit:
+ await query.answer("Configs not found", show_alert=True)
+ return
+
+ # Store edit loop state
+ context.user_data["cfg_edit_loop"] = configs_to_edit
+ context.user_data["cfg_edit_index"] = 0
+ context.user_data["cfg_edit_modified"] = {} # {config_id: modified_config}
+
+ await show_cfg_edit_form(update, context)
+
+
+def _get_editable_config_fields(config: dict) -> dict:
+ """Extract editable fields from a controller config"""
+ controller_type = config.get("controller_name", "grid_strike")
+ tp_cfg = config.get("triple_barrier_config", {})
+ take_profit = tp_cfg.get("take_profit", 0.0001) if isinstance(tp_cfg, dict) else 0.0001
+
+ if "grid_strike" in controller_type:
+ return {
+ "start_price": config.get("start_price", 0),
+ "end_price": config.get("end_price", 0),
+ "limit_price": config.get("limit_price", 0),
+ "total_amount_quote": config.get("total_amount_quote", 0),
+ "max_open_orders": config.get("max_open_orders", 3),
+ "max_orders_per_batch": config.get("max_orders_per_batch", 1),
+ "min_spread_between_orders": config.get("min_spread_between_orders", 0.0001),
+ "activation_bounds": config.get("activation_bounds", 0.01),
+ "take_profit": take_profit,
+ }
+ # Default fields for other controller types
+ return {
+ "total_amount_quote": config.get("total_amount_quote", 0),
+ "take_profit": take_profit,
+ }
+
+
+async def show_cfg_edit_form(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show edit form for current config in bulk edit format (key=value)"""
query = update.callback_query
- config = get_controller_config(context)
- config["leverage"] = leverage
+ configs_to_edit = context.user_data.get("cfg_edit_loop", [])
+ current_idx = context.user_data.get("cfg_edit_index", 0)
+ modified = context.user_data.get("cfg_edit_modified", {})
+
+ if not configs_to_edit or current_idx >= len(configs_to_edit):
+ await show_controller_configs_menu(update, context)
+ return
+
+ total = len(configs_to_edit)
+ config = configs_to_edit[current_idx]
+ config_id = config.get("id", "unknown")
+
+ # Check if we have modifications for this config
+ if config_id in modified:
+ config = modified[config_id]
+
+ # Store current config for editing
set_controller_config(context, config)
- # Move to amount step
- context.user_data["gs_wizard_step"] = "total_amount_quote"
- await _show_wizard_amount_step(update, context)
+ # Get editable fields
+ editable_fields = _get_editable_config_fields(config)
+ # Store editable fields and set state for bulk edit
+ context.user_data["cfg_editable_fields"] = editable_fields
+ context.user_data["bots_state"] = "cfg_bulk_edit"
+ context.user_data["cfg_edit_message_id"] = query.message.message_id if not query.message.photo else None
+ context.user_data["cfg_edit_chat_id"] = query.message.chat_id
-async def _show_wizard_amount_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Wizard Step 5: Enter Amount"""
- query = update.callback_query
- config = get_controller_config(context)
+ # Build message with key=value format
+ lines = [f"*Edit Config* \\({current_idx + 1}/{total}\\)", ""]
+ lines.append(f"`{escape_markdown_v2(config_id)}`")
+ lines.append("")
- connector = config.get("connector_name", "")
- pair = config.get("trading_pair", "")
- side = "📈 LONG" if config.get("side") == SIDE_LONG else "📉 SHORT"
- leverage = config.get("leverage", 1)
+ # Build config text for display (each line copyable)
+ for key, value in editable_fields.items():
+ lines.append(f"`{key}={value}`")
+ lines.append("")
+ lines.append("✏️ _Send `key=value` to update_")
- context.user_data["bots_state"] = "gs_wizard_input"
- context.user_data["gs_wizard_step"] = "total_amount_quote"
+ # Build keyboard - simplified, no field buttons
+ keyboard = []
- keyboard = [
- [
- InlineKeyboardButton("💵 100", callback_data="bots:gs_amount:100"),
- InlineKeyboardButton("💵 500", callback_data="bots:gs_amount:500"),
- InlineKeyboardButton("💵 1000", callback_data="bots:gs_amount:1000"),
- ],
- [
- InlineKeyboardButton("💰 2000", callback_data="bots:gs_amount:2000"),
- InlineKeyboardButton("💰 5000", callback_data="bots:gs_amount:5000"),
- ],
- [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")],
- ]
+ # Navigation row
+ nav_row = []
+ if current_idx > 0:
+ nav_row.append(InlineKeyboardButton("◀️ Prev", callback_data="bots:cfg_edit_prev"))
+ nav_row.append(InlineKeyboardButton(f"💾 Save", callback_data="bots:cfg_edit_save"))
+ if current_idx < total - 1:
+ nav_row.append(InlineKeyboardButton("Next ▶️", callback_data="bots:cfg_edit_next"))
+ keyboard.append(nav_row)
+
+ # Final row
+ keyboard.append([
+ InlineKeyboardButton("💾 Save All & Exit", callback_data="bots:cfg_edit_save_all"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:cfg_edit_cancel"),
+ ])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
await query.message.edit_text(
- r"*📈 Grid Strike \- New Config*" + "\n\n"
- f"🏦 *Connector:* `{escape_markdown_v2(connector)}`" + "\n"
- f"🔗 *Pair:* `{escape_markdown_v2(pair)}`" + "\n"
- f"🎯 *Side:* `{side}` \\| ⚡ *Leverage:* `{leverage}x`" + "\n\n"
- r"*Step 5/7:* 💰 Total Amount \(Quote\)" + "\n\n"
- r"Select or type amount in quote currency:",
+ "\n".join(lines),
parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
+ reply_markup=reply_markup
)
-async def handle_gs_wizard_amount(update: Update, context: ContextTypes.DEFAULT_TYPE, amount: float) -> None:
- """Handle amount selection in wizard"""
+async def handle_cfg_edit_field(update: Update, context: ContextTypes.DEFAULT_TYPE, field_name: str) -> None:
+ """Prompt to edit a field in the current config"""
query = update.callback_query
config = get_controller_config(context)
- config["total_amount_quote"] = amount
- set_controller_config(context, config)
+ if not config:
+ await query.answer("Config not found", show_alert=True)
+ return
- # Check if market data is ready (pre-fetched in background)
- market_data_ready = context.user_data.get("gs_market_data_ready", False)
- pair = config.get("trading_pair", "")
+ # Get current value
+ if field_name == "take_profit":
+ current_value = config.get("triple_barrier_config", {}).get("take_profit", 0.0001)
+ elif field_name == "side":
+ # Toggle side directly
+ current_side = config.get("side", 1)
+ new_side = 2 if current_side == 1 else 1
+ config["side"] = new_side
+
+ # Store modified config
+ config_id = config.get("id")
+ modified = context.user_data.get("cfg_edit_modified", {})
+ modified[config_id] = config
+ context.user_data["cfg_edit_modified"] = modified
+
+ # Update in edit loop
+ configs_to_edit = context.user_data.get("cfg_edit_loop", [])
+ current_idx = context.user_data.get("cfg_edit_index", 0)
+ if current_idx < len(configs_to_edit):
+ configs_to_edit[current_idx] = config
+
+ await show_cfg_edit_form(update, context)
+ return
+ else:
+ current_value = config.get(field_name, "")
+
+ # Get field info
+ field_labels = {
+ "leverage": ("Leverage", "Enter leverage (1-20)"),
+ "total_amount_quote": ("Amount (USDT)", "Enter total amount in quote currency"),
+ "start_price": ("Start Price", "Enter start price"),
+ "end_price": ("End Price", "Enter end price"),
+ "limit_price": ("Limit Price", "Enter limit/stop price"),
+ "take_profit": ("Take Profit", "Enter take profit (e.g., 0.01 = 1%)"),
+ "max_open_orders": ("Max Open Orders", "Enter max open orders (1-10)"),
+ }
- # Show loading indicator if market data is not ready yet
- if not market_data_ready:
- await query.message.edit_text(
- r"*📈 Grid Strike \- New Config*" + "\n\n"
- f"⏳ *Loading chart for* `{escape_markdown_v2(pair)}`\\.\\.\\." + "\n\n"
- r"_Fetching market data and generating chart\\._",
- parse_mode="MarkdownV2"
- )
+ label, hint = field_labels.get(field_name, (field_name, "Enter value"))
- # Move to prices step - this will fetch OHLC and show chart
- context.user_data["gs_wizard_step"] = "prices"
- await _show_wizard_prices_step(update, context)
+ # Store state for input processing
+ context.user_data["bots_state"] = f"cfg_edit_input:{field_name}"
+ context.user_data["cfg_edit_field"] = field_name
+ lines = [
+ f"*Edit {escape_markdown_v2(label)}*",
+ "",
+ f"Current: `{escape_markdown_v2(str(current_value))}`",
+ "",
+ f"_{escape_markdown_v2(hint)}_",
+ ]
-async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT_TYPE, interval: str = None) -> None:
- """Wizard Step 6: Price Configuration with OHLC chart"""
- query = update.callback_query
- config = get_controller_config(context)
+ keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:cfg_edit_form")]]
- connector = config.get("connector_name", "")
- pair = config.get("trading_pair", "")
- side = config.get("side", SIDE_LONG)
+ await query.message.edit_text(
+ "\n".join(lines),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- # Get current interval (default 5m)
- if interval is None:
- interval = context.user_data.get("gs_chart_interval", "5m")
- context.user_data["gs_chart_interval"] = interval
- # Check if we have pre-cached data from background fetch
- current_price = context.user_data.get("gs_current_price")
- candles = context.user_data.get("gs_candles")
- market_data_ready = context.user_data.get("gs_market_data_ready", False)
- market_data_error = context.user_data.get("gs_market_data_error")
+async def process_cfg_edit_input(update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str) -> None:
+ """Process user input for config bulk edit - parses key=value lines"""
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ editable_fields = context.user_data.get("cfg_editable_fields", {})
+
+ if not config:
+ await update.message.reply_text("Context lost. Please start over.")
+ return
+ # Delete user's input message for clean chat
try:
- # If no cached data or interval changed, fetch now
- cached_interval = context.user_data.get("gs_candles_interval", "5m")
- need_refetch = interval != cached_interval
+ await update.message.delete()
+ except Exception:
+ pass
- if not current_price or need_refetch:
- # Show loading message - handle both text and photo messages
- try:
- await query.message.edit_text(
- r"*📈 Grid Strike \- New Config*" + "\n\n"
- f"⏳ Fetching market data for `{escape_markdown_v2(pair)}`\\.\\.\\.",
- parse_mode="MarkdownV2"
- )
- except Exception:
- # Message is likely a photo - delete it and send new text message
- try:
- await query.message.delete()
- except Exception:
- pass
- loading_msg = await context.bot.send_message(
- chat_id=query.message.chat_id,
- text=(
- r"*📈 Grid Strike \- New Config*" + "\n\n"
- f"⏳ Fetching market data for `{escape_markdown_v2(pair)}`\\.\\.\\."
- ),
- parse_mode="MarkdownV2"
- )
- # Update the wizard message ID to the new loading message
- context.user_data["gs_wizard_message_id"] = loading_msg.message_id
+ # Parse key=value lines
+ updates = {}
+ errors = []
- client = await get_bots_client()
- current_price = await fetch_current_price(client, connector, pair)
+ for line in user_input.split('\n'):
+ line = line.strip()
+ if not line or '=' not in line:
+ continue
- if current_price:
- context.user_data["gs_current_price"] = current_price
- candles = await fetch_candles(client, connector, pair, interval=interval, max_records=500)
- context.user_data["gs_candles"] = candles
- context.user_data["gs_candles_interval"] = interval
+ key, _, value = line.partition('=')
+ key = key.strip()
+ value = value.strip()
- if not current_price:
- keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:controller_configs")]]
- try:
- await query.message.edit_text(
- r"*❌ Error*" + "\n\n"
- f"Could not fetch price for `{escape_markdown_v2(pair)}`\\.\n"
- r"Please check the trading pair and try again\\.",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- except Exception:
- # Message might be a photo or already deleted
- await context.bot.send_message(
- chat_id=query.message.chat_id,
- text=(
- r"*❌ Error*" + "\n\n"
- f"Could not fetch price for `{escape_markdown_v2(pair)}`\\.\n"
- r"Please check the trading pair and try again\\."
- ),
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- return
+ # Validate key exists in editable fields
+ if key not in editable_fields:
+ errors.append(f"Unknown: {key}")
+ continue
- # Calculate auto prices only if not already set (preserve user edits)
- if not config.get("start_price") or not config.get("end_price"):
- start, end, limit = calculate_auto_prices(current_price, side)
- config["start_price"] = start
- config["end_price"] = end
- config["limit_price"] = limit
- else:
- start = config.get("start_price")
- end = config.get("end_price")
- limit = config.get("limit_price")
+ # Convert value to appropriate type
+ current_val = editable_fields.get(key)
+ try:
+ if isinstance(current_val, bool):
+ parsed_value = value.lower() in ['true', '1', 'yes', 'y', 'on']
+ elif isinstance(current_val, int):
+ parsed_value = int(value)
+ elif isinstance(current_val, float):
+ parsed_value = float(value)
+ else:
+ parsed_value = value
+ updates[key] = parsed_value
+ except ValueError:
+ errors.append(f"Invalid: {key}={value}")
- # Generate config ID with sequence number (if not already set)
- if not config.get("id"):
- existing_configs = context.user_data.get("controller_configs_list", [])
- config["id"] = generate_config_id(connector, pair, existing_configs=existing_configs)
+ if errors:
+ error_msg = "⚠️ " + ", ".join(errors)
+ await update.get_bot().send_message(chat_id=chat_id, text=error_msg)
- set_controller_config(context, config)
+ if not updates:
+ await update.get_bot().send_message(
+ chat_id=chat_id,
+ text="❌ No valid updates found. Use format: key=value"
+ )
+ return
- # Show price edit options
- side_str = "📈 LONG" if side == SIDE_LONG else "📉 SHORT"
+ # Apply updates to config
+ for key, value in updates.items():
+ if key == "take_profit":
+ if "triple_barrier_config" not in config:
+ config["triple_barrier_config"] = {}
+ config["triple_barrier_config"]["take_profit"] = value
+ else:
+ config[key] = value
- context.user_data["bots_state"] = "gs_wizard_input"
- context.user_data["gs_wizard_step"] = "prices"
+ # Store modified config
+ config_id = config.get("id")
+ modified = context.user_data.get("cfg_edit_modified", {})
+ modified[config_id] = config
+ context.user_data["cfg_edit_modified"] = modified
- # Build interval buttons with current one highlighted
- interval_options = ["1m", "5m", "15m", "1h", "4h"]
- interval_row = []
- for opt in interval_options:
- label = f"✓ {opt}" if opt == interval else opt
- interval_row.append(InlineKeyboardButton(label, callback_data=f"bots:gs_interval:{opt}"))
+ # Update in edit loop
+ configs_to_edit = context.user_data.get("cfg_edit_loop", [])
+ current_idx = context.user_data.get("cfg_edit_index", 0)
+ if current_idx < len(configs_to_edit):
+ configs_to_edit[current_idx] = config
- keyboard = [
- interval_row,
- [
- InlineKeyboardButton("✅ Accept Prices", callback_data="bots:gs_accept_prices"),
- ],
- [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")],
- ]
+ # Update editable fields for display
+ context.user_data["cfg_editable_fields"] = _get_editable_config_fields(config)
- # Format example with current values
- example_prices = f"{start:,.6g},{end:,.6g},{limit:,.6g}"
+ # Format updated fields
+ updated_lines = [f"`{escape_markdown_v2(k)}` \\= `{escape_markdown_v2(str(v))}`" for k, v in updates.items()]
- # Build the caption
- config_text = (
- f"*📊 {escape_markdown_v2(pair)}* \\- Grid Zone Preview\n\n"
- f"🏦 *Connector:* `{escape_markdown_v2(connector)}`\n"
- f"🎯 *Side:* `{side_str}` \\| ⚡ *Leverage:* `{config.get('leverage', 1)}x`\n"
- f"💰 *Amount:* `{config.get('total_amount_quote', 0):,.0f}`\n\n"
- f"📍 Current: `{current_price:,.6g}`\n"
- f"🟢 Start: `{start:,.6g}`\n"
- f"🔵 End: `{end:,.6g}`\n"
- f"🔴 Limit: `{limit:,.6g}`\n\n"
- f"_Type `start,end,limit` to edit_\n"
- f"_e\\.g\\. `{escape_markdown_v2(example_prices)}`_"
- )
+ keyboard = [[InlineKeyboardButton("✅ Continue", callback_data="bots:cfg_edit_form")]]
- # Generate chart and send as photo with caption
- if candles:
- chart_bytes = generate_candles_chart(
- candles, pair,
- start_price=start,
- end_price=end,
- limit_price=limit,
- current_price=current_price
- )
+ await update.get_bot().send_message(
+ chat_id=chat_id,
+ text=f"✅ *Updated*\n\n" + "\n".join(updated_lines) + "\n\n_Tap to continue editing_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- # Delete old message and send photo with caption + buttons
- try:
- await query.message.delete()
- except:
- pass
- msg = await context.bot.send_photo(
- chat_id=query.message.chat_id,
- photo=chart_bytes,
- caption=config_text,
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
+async def handle_cfg_edit_prev(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Go to previous config in edit loop"""
+ current_idx = context.user_data.get("cfg_edit_index", 0)
+ if current_idx > 0:
+ context.user_data["cfg_edit_index"] = current_idx - 1
+ await show_cfg_edit_form(update, context)
- # Store as wizard message (photo with buttons)
- context.user_data["gs_wizard_message_id"] = msg.message_id
- context.user_data["gs_wizard_chat_id"] = query.message.chat_id
- else:
- # No chart - just edit text message
- await query.message.edit_text(
- text=config_text,
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- context.user_data["gs_wizard_message_id"] = query.message.message_id
- except Exception as e:
- logger.error(f"Error in prices step: {e}", exc_info=True)
- keyboard = [[InlineKeyboardButton("Back", callback_data="bots:controller_configs")]]
- await query.message.edit_text(
- format_error_message(f"Error fetching market data: {str(e)}"),
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
+async def handle_cfg_edit_next(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Go to next config in edit loop"""
+ configs_to_edit = context.user_data.get("cfg_edit_loop", [])
+ current_idx = context.user_data.get("cfg_edit_index", 0)
+ if current_idx < len(configs_to_edit) - 1:
+ context.user_data["cfg_edit_index"] = current_idx + 1
+ await show_cfg_edit_form(update, context)
-async def handle_gs_accept_prices(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Accept prices and move to take profit step"""
+async def handle_cfg_edit_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Save current config and stay in edit loop"""
query = update.callback_query
+ chat_id = update.effective_chat.id
+
config = get_controller_config(context)
+ if not config:
+ await query.answer("Config not found", show_alert=True)
+ return
- side = config.get("side", SIDE_LONG)
- start_price = config.get("start_price", 0)
- end_price = config.get("end_price", 0)
- limit_price = config.get("limit_price", 0)
+ config_id = config.get("id")
- # Validate price ordering based on side
- # LONG: limit_price < start_price < end_price
- # SHORT: start_price < end_price < limit_price
- validation_error = None
- if side == SIDE_LONG:
- if not (limit_price < start_price < end_price):
- validation_error = (
- "Invalid prices for LONG position\\.\n\n"
- "Required: `limit < start < end`\n"
- f"Current: `{limit_price:,.6g}` < `{start_price:,.6g}` < `{end_price:,.6g}`"
- )
- else: # SHORT
- if not (start_price < end_price < limit_price):
- validation_error = (
- "Invalid prices for SHORT position\\.\n\n"
- "Required: `start < end < limit`\n"
- f"Current: `{start_price:,.6g}` < `{end_price:,.6g}` < `{limit_price:,.6g}`"
- )
+ try:
+ client = await get_bots_client(chat_id)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+ await query.answer(f"✅ Saved {config_id[:20]}")
- if validation_error:
- await query.answer("Invalid price configuration", show_alert=True)
- # Clean up the chart photo if it exists
- # Show error - delete photo and send text message
- keyboard = [
- [InlineKeyboardButton("Edit Prices", callback_data="bots:gs_back_to_prices")],
- [InlineKeyboardButton("Cancel", callback_data="bots:controller_configs")],
- ]
- try:
- await query.message.delete()
- except:
- pass
- msg = await context.bot.send_message(
- chat_id=query.message.chat_id,
- text=f"⚠️ *Price Validation Error*\n\n{validation_error}",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- context.user_data["gs_wizard_message_id"] = msg.message_id
- context.user_data["gs_wizard_chat_id"] = query.message.chat_id
+ # Remove from modified since it's now saved
+ modified = context.user_data.get("cfg_edit_modified", {})
+ modified.pop(config_id, None)
+ context.user_data["cfg_edit_modified"] = modified
+
+ except Exception as e:
+ logger.error(f"Failed to save config {config_id}: {e}")
+ await query.answer(f"❌ Save failed: {str(e)[:30]}", show_alert=True)
+
+
+async def handle_cfg_edit_save_all(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Save all modified configs and exit edit loop"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ modified = context.user_data.get("cfg_edit_modified", {})
+
+ if not modified:
+ await query.answer("No changes to save")
+ # Clean up edit loop state
+ context.user_data.pop("cfg_edit_loop", None)
+ context.user_data.pop("cfg_edit_index", None)
+ context.user_data.pop("cfg_edit_modified", None)
+ await show_controller_configs_menu(update, context)
return
- context.user_data["gs_wizard_step"] = "take_profit"
- await _show_wizard_take_profit_step(update, context)
+ # Show progress
+ await query.message.edit_text(
+ f"💾 Saving {len(modified)} config{'s' if len(modified) != 1 else ''}\\.\\.\\.",
+ parse_mode="MarkdownV2"
+ )
+ client = await get_bots_client(chat_id)
+ saved = []
+ failed = []
-async def handle_gs_back_to_prices(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Go back to prices step from validation error"""
- context.user_data["gs_wizard_step"] = "prices"
- await _show_wizard_prices_step(update, context)
+ for config_id, config in modified.items():
+ try:
+ await client.controllers.create_or_update_controller_config(config_id, config)
+ saved.append(config_id)
+ except Exception as e:
+ logger.error(f"Failed to save config {config_id}: {e}")
+ failed.append((config_id, str(e)))
+
+ # Clean up edit loop state
+ context.user_data.pop("cfg_edit_loop", None)
+ context.user_data.pop("cfg_edit_index", None)
+ context.user_data.pop("cfg_edit_modified", None)
+
+ # Build result message
+ lines = []
+ if saved:
+ lines.append(f"✅ *Saved {len(saved)} config{'s' if len(saved) != 1 else ''}*")
+ for cfg_id in saved[:5]:
+ lines.append(f" • `{escape_markdown_v2(cfg_id)}`")
+ if len(saved) > 5:
+ lines.append(f" _\\.\\.\\.and {len(saved) - 5} more_")
+
+ if failed:
+ lines.append("")
+ lines.append(f"❌ *Failed to save {len(failed)}:*")
+ for cfg_id, error in failed[:3]:
+ lines.append(f" • `{escape_markdown_v2(cfg_id)}`")
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:controller_configs")]]
-async def handle_gs_interval_change(update: Update, context: ContextTypes.DEFAULT_TYPE, interval: str) -> None:
- """Handle interval change for chart - refetch candles with new interval"""
- query = update.callback_query
+ await query.message.edit_text(
+ "\n".join(lines),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- # Clear cached candles to force refetch
- context.user_data.pop("gs_candles", None)
- context.user_data["gs_chart_interval"] = interval
- # Redisplay prices step with new interval
- await _show_wizard_prices_step(update, context, interval=interval)
+async def handle_cfg_edit_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Cancel edit loop without saving"""
+ # Clean up edit loop state
+ context.user_data.pop("cfg_edit_loop", None)
+ context.user_data.pop("cfg_edit_index", None)
+ context.user_data.pop("cfg_edit_modified", None)
+ context.user_data.pop("bots_state", None)
+
+ await show_controller_configs_menu(update, context)
-async def _show_wizard_take_profit_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Wizard Step 7: Take Profit Configuration"""
+async def handle_configs_page(update: Update, context: ContextTypes.DEFAULT_TYPE, page: int) -> None:
+ """Handle pagination for controller configs menu (legacy, redirects to cfg_page)"""
+ controller_type = context.user_data.get("configs_controller_type")
+ if controller_type:
+ await show_configs_by_type(update, context, controller_type, page)
+ else:
+ await show_controller_configs_menu(update, context, page=page)
+
+
+# ============================================
+# LIST EXISTING CONFIGS (DEPRECATED - merged into show_controller_configs_menu)
+# ============================================
+
+async def show_configs_list(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Redirect to controller configs menu (backward compatibility)"""
+ await show_controller_configs_menu(update, context)
+
+
+# ============================================
+# PROGRESSIVE GRID STRIKE WIZARD
+# ============================================
+
+async def show_new_grid_strike_form(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Start the progressive Grid Strike wizard - Step 1: Connector"""
query = update.callback_query
- config = get_controller_config(context)
+ chat_id = update.effective_chat.id
- connector = config.get("connector_name", "")
- pair = config.get("trading_pair", "")
- side = "📈 LONG" if config.get("side") == SIDE_LONG else "📉 SHORT"
+ # Fetch existing configs for sequence numbering
+ try:
+ client = await get_bots_client(chat_id)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs for sequencing: {e}")
- context.user_data["bots_state"] = "gs_wizard_input"
- context.user_data["gs_wizard_step"] = "take_profit"
+ # Initialize new config with defaults
+ config = init_new_controller_config(context, "grid_strike")
+ context.user_data["bots_state"] = "gs_wizard"
+ context.user_data["gs_wizard_step"] = "connector_name"
+ context.user_data["gs_wizard_message_id"] = query.message.message_id
+ context.user_data["gs_wizard_chat_id"] = query.message.chat_id
- keyboard = [
- [
- InlineKeyboardButton("0.01%", callback_data="bots:gs_tp:0.0001"),
- InlineKeyboardButton("0.02%", callback_data="bots:gs_tp:0.0002"),
- InlineKeyboardButton("0.05%", callback_data="bots:gs_tp:0.0005"),
- ],
- [
- InlineKeyboardButton("0.1%", callback_data="bots:gs_tp:0.001"),
- InlineKeyboardButton("0.2%", callback_data="bots:gs_tp:0.002"),
- InlineKeyboardButton("0.5%", callback_data="bots:gs_tp:0.005"),
- ],
- [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")],
- ]
+ # Show connector selector directly
+ await _show_wizard_connector_step(update, context)
- message_text = (
- r"*📈 Grid Strike \- New Config*" + "\n\n"
- f"🏦 *Connector:* `{escape_markdown_v2(connector)}`" + "\n"
- f"🔗 *Pair:* `{escape_markdown_v2(pair)}`" + "\n"
- f"🎯 *Side:* `{side}` \\| ⚡ *Leverage:* `{config.get('leverage', 1)}x`" + "\n"
- f"💰 *Amount:* `{config.get('total_amount_quote', 0):,.0f}`" + "\n"
- f"📊 *Grid:* `{config.get('start_price', 0):,.6g}` \\- `{config.get('end_price', 0):,.6g}`" + "\n\n"
- r"*Step 7/7:* 🎯 Take Profit" + "\n\n"
- r"Select or type take profit % \(e\.g\. `0\.4` for 0\.4%\):"
- )
- # Delete photo message and send text message
+async def _show_wizard_connector_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Wizard Step 1: Select Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
try:
- await query.message.delete()
- except:
- pass
+ client = await get_bots_client(chat_id)
+ cex_connectors = await get_available_cex_connectors(context.user_data, client)
- msg = await context.bot.send_message(
- chat_id=query.message.chat_id,
- text=message_text,
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
+ if not cex_connectors:
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ await query.message.edit_text(
+ r"*Grid Strike \- New Config*" + "\n\n"
+ r"No CEX connectors configured\." + "\n"
+ r"Please configure exchange credentials first\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ return
- context.user_data["gs_wizard_message_id"] = msg.message_id
- context.user_data["gs_wizard_chat_id"] = query.message.chat_id
+ # Build connector buttons (2 per row)
+ keyboard = []
+ row = []
+ for connector in cex_connectors:
+ row.append(InlineKeyboardButton(f"🏦 {connector}", callback_data=f"bots:gs_connector:{connector}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
-async def handle_gs_wizard_take_profit(update: Update, context: ContextTypes.DEFAULT_TYPE, tp: float) -> None:
- """Handle take profit selection and show final review"""
+ await query.message.edit_text(
+ r"*📈 Grid Strike \- Step 1*" + "\n\n"
+ r"🏦 *Select Connector*" + "\n\n"
+ r"Choose the exchange for this grid \(spot or perpetual\):",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
+ except Exception as e:
+ logger.error(f"Error in connector step: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ await query.message.edit_text(
+ format_error_message(f"Error: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
+
+async def handle_gs_wizard_connector(update: Update, context: ContextTypes.DEFAULT_TYPE, connector: str) -> None:
+ """Handle connector selection in wizard"""
query = update.callback_query
config = get_controller_config(context)
- if "triple_barrier_config" not in config:
- config["triple_barrier_config"] = GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy()
- config["triple_barrier_config"]["take_profit"] = tp
+ config["connector_name"] = connector
set_controller_config(context, config)
- # Move to review step
- context.user_data["gs_wizard_step"] = "review"
- await _show_wizard_review_step(update, context)
+ # Move to trading pair step
+ context.user_data["gs_wizard_step"] = "trading_pair"
+ await _show_wizard_pair_step(update, context)
-async def _show_wizard_review_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Final Review Step with copyable config format"""
+async def handle_gs_wizard_pair(update: Update, context: ContextTypes.DEFAULT_TYPE, pair: str) -> None:
+ """Handle trading pair selection from button in wizard"""
query = update.callback_query
+ chat_id = update.effective_chat.id
config = get_controller_config(context)
- connector = config.get("connector_name", "")
- pair = config.get("trading_pair", "")
- side = "LONG" if config.get("side") == SIDE_LONG else "SHORT"
- leverage = config.get("leverage", 1)
- amount = config.get("total_amount_quote", 0)
- start_price = config.get("start_price", 0)
- end_price = config.get("end_price", 0)
- limit_price = config.get("limit_price", 0)
- tp = config.get("triple_barrier_config", {}).get("take_profit", 0.0001)
- keep_position = config.get("keep_position", True)
- activation_bounds = config.get("activation_bounds", 0.01)
- config_id = config.get("id", "")
- max_open_orders = config.get("max_open_orders", 3)
- max_orders_per_batch = config.get("max_orders_per_batch", 1)
- min_order_amount = config.get("min_order_amount_quote", 6)
- min_spread = config.get("min_spread_between_orders", 0.0002)
+ config["trading_pair"] = pair.upper()
+ set_controller_config(context, config)
- # Delete previous chart if exists
- chart_msg_id = context.user_data.pop("gs_chart_message_id", None)
- if chart_msg_id:
- try:
- await context.bot.delete_message(
- chat_id=query.message.chat_id,
- message_id=chart_msg_id
- )
- except:
- pass
+ # Start background fetch of market data
+ asyncio.create_task(_background_fetch_market_data(context, config, chat_id))
- context.user_data["bots_state"] = "gs_wizard_input"
- context.user_data["gs_wizard_step"] = "review"
+ # Move to side step
+ context.user_data["gs_wizard_step"] = "side"
+ await _show_wizard_side_step(update, context)
- # Build copyable config block with real YAML field names
- side_value = config.get("side", SIDE_LONG)
- config_block = (
- f"id: {config_id}\n"
- f"connector_name: {connector}\n"
- f"trading_pair: {pair}\n"
- f"side: {side_value}\n"
- f"leverage: {leverage}\n"
- f"total_amount_quote: {amount:.0f}\n"
- f"start_price: {start_price:.6g}\n"
- f"end_price: {end_price:.6g}\n"
- f"limit_price: {limit_price:.6g}\n"
- f"take_profit: {tp}\n"
- f"keep_position: {str(keep_position).lower()}\n"
- f"activation_bounds: {activation_bounds}\n"
- f"max_open_orders: {max_open_orders}\n"
- f"max_orders_per_batch: {max_orders_per_batch}\n"
- f"min_order_amount_quote: {min_order_amount}\n"
- f"min_spread_between_orders: {min_spread}"
- )
- message_text = (
- f"*{escape_markdown_v2(pair)}* \\- Review Config\n\n"
- f"```\n{config_block}\n```\n\n"
- f"_To edit, send `field: value` lines:_\n"
- f"`leverage: 75`\n"
- f"`total_amount_quote: 1000`"
- )
+async def _show_wizard_pair_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Wizard Step 2: Enter Trading Pair"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ context.user_data["bots_state"] = "gs_wizard_input"
+ context.user_data["gs_wizard_step"] = "trading_pair"
- keyboard = [
- [
- InlineKeyboardButton("✅ Save Config", callback_data="bots:gs_save"),
- ],
- [
- InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs"),
- ],
- ]
+ # Get recent pairs from existing configs (max 6)
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ recent_pairs = []
+ seen_pairs = set()
+ for cfg in reversed(existing_configs): # Most recent first
+ pair = cfg.get("trading_pair", "")
+ if pair and pair not in seen_pairs:
+ seen_pairs.add(pair)
+ recent_pairs.append(pair)
+ if len(recent_pairs) >= 6:
+ break
+
+ # Build keyboard with recent pairs (2 per row) + cancel
+ keyboard = []
+ if recent_pairs:
+ row = []
+ for pair in recent_pairs:
+ row.append(InlineKeyboardButton(pair, callback_data=f"bots:gs_pair:{pair}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:gs_back_to_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ recent_hint = ""
+ if recent_pairs:
+ recent_hint = "\n\nOr type a custom pair below:"
+
+ # Determine total steps based on connector type
+ is_perp = connector.endswith("_perpetual")
+ total_steps = 6 if is_perp else 5
await query.message.edit_text(
- message_text,
+ rf"*📈 Grid Strike \- Step 2/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n"
+ r"🔗 *Trading Pair*" + "\n\n"
+ r"Select a recent pair or enter a new one:" + escape_markdown_v2(recent_hint),
parse_mode="MarkdownV2",
reply_markup=InlineKeyboardMarkup(keyboard)
)
-async def _update_wizard_message_for_review(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Update wizard to show review step with copyable config format"""
- message_id = context.user_data.get("gs_wizard_message_id")
- chat_id = context.user_data.get("gs_wizard_chat_id")
-
- if not message_id or not chat_id:
- return
-
+async def _show_wizard_side_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Wizard Step 3: Select Side"""
+ query = update.callback_query
config = get_controller_config(context)
connector = config.get("connector_name", "")
pair = config.get("trading_pair", "")
- side = "LONG" if config.get("side") == SIDE_LONG else "SHORT"
- leverage = config.get("leverage", 1)
- amount = config.get("total_amount_quote", 0)
- start_price = config.get("start_price", 0)
- end_price = config.get("end_price", 0)
- limit_price = config.get("limit_price", 0)
- tp = config.get("triple_barrier_config", {}).get("take_profit", 0.0001)
- keep_position = config.get("keep_position", True)
- activation_bounds = config.get("activation_bounds", 0.01)
- config_id = config.get("id", "")
- max_open_orders = config.get("max_open_orders", 3)
- max_orders_per_batch = config.get("max_orders_per_batch", 1)
- min_order_amount = config.get("min_order_amount_quote", 6)
- min_spread = config.get("min_spread_between_orders", 0.0002)
-
- # Build copyable config block with real YAML field names
- side_value = config.get("side", SIDE_LONG)
- config_block = (
- f"id: {config_id}\n"
- f"connector_name: {connector}\n"
- f"trading_pair: {pair}\n"
- f"side: {side_value}\n"
- f"leverage: {leverage}\n"
- f"total_amount_quote: {amount:.0f}\n"
- f"start_price: {start_price:.6g}\n"
- f"end_price: {end_price:.6g}\n"
- f"limit_price: {limit_price:.6g}\n"
- f"take_profit: {tp}\n"
- f"keep_position: {str(keep_position).lower()}\n"
- f"activation_bounds: {activation_bounds}\n"
- f"max_open_orders: {max_open_orders}\n"
- f"max_orders_per_batch: {max_orders_per_batch}\n"
- f"min_order_amount_quote: {min_order_amount}\n"
- f"min_spread_between_orders: {min_spread}"
- )
-
- message_text = (
- f"*{escape_markdown_v2(pair)}* \\- Review Config\n\n"
- f"```\n{config_block}\n```\n\n"
- f"_To edit, send `field: value` lines:_\n"
- f"`leverage: 75`\n"
- f"`total_amount_quote: 1000`"
- )
keyboard = [
[
- InlineKeyboardButton("✅ Save Config", callback_data="bots:gs_save"),
+ InlineKeyboardButton("📈 LONG", callback_data="bots:gs_side:long"),
+ InlineKeyboardButton("📉 SHORT", callback_data="bots:gs_side:short"),
],
[
- InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs"),
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:gs_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
],
]
- try:
- await context.bot.edit_message_text(
- chat_id=chat_id,
- message_id=message_id,
- text=message_text,
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- except Exception as e:
- logger.error(f"Error updating review message: {e}")
-
-
-async def handle_gs_edit_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Allow user to edit config ID before saving"""
- query = update.callback_query
- config = get_controller_config(context)
- chat_id = query.message.chat_id
-
- context.user_data["bots_state"] = "gs_wizard_input"
- context.user_data["gs_wizard_step"] = "edit_id"
-
- current_id = config.get("id", "")
-
- keyboard = [
- [InlineKeyboardButton(f"Keep: {current_id[:25]}", callback_data="bots:gs_save")],
- [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")],
- ]
-
- # Delete current message (could be photo)
- try:
- await query.message.delete()
- except:
- pass
+ # Determine total steps based on connector type
+ is_perp = connector.endswith("_perpetual")
+ total_steps = 6 if is_perp else 5
- msg = await context.bot.send_message(
- chat_id=chat_id,
- text=r"*Edit Config ID*" + "\n\n"
- f"Current: `{escape_markdown_v2(current_id)}`" + "\n\n"
- r"Type a new ID or tap Keep to use current:",
+ await query.message.edit_text(
+ rf"*📈 Grid Strike \- Step 3/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"🎯 *Select Side*",
parse_mode="MarkdownV2",
reply_markup=InlineKeyboardMarkup(keyboard)
)
- context.user_data["gs_wizard_message_id"] = msg.message_id
-async def handle_gs_edit_keep(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Toggle keep_position setting"""
+async def handle_gs_wizard_side(update: Update, context: ContextTypes.DEFAULT_TYPE, side_str: str) -> None:
+ """Handle side selection in wizard"""
query = update.callback_query
config = get_controller_config(context)
- # Toggle the value
- current = config.get("keep_position", True)
- config["keep_position"] = not current
- context.user_data["controller_config"] = config
+ config["side"] = SIDE_LONG if side_str == "long" else SIDE_SHORT
+ set_controller_config(context, config)
- # Go back to review
- await _show_wizard_review_step(update, context)
+ connector = config.get("connector_name", "")
+ # Only ask for leverage on perpetual exchanges
+ if connector.endswith("_perpetual"):
+ context.user_data["gs_wizard_step"] = "leverage"
+ await _show_wizard_leverage_step(update, context)
+ else:
+ # Spot exchange - set leverage to 1 and skip to amount
+ config["leverage"] = 1
+ set_controller_config(context, config)
+ context.user_data["gs_wizard_step"] = "total_amount_quote"
+ await _show_wizard_amount_step(update, context)
-async def handle_gs_edit_tp(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Edit take profit"""
+
+async def _show_wizard_leverage_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Wizard Step 4: Select Leverage"""
query = update.callback_query
config = get_controller_config(context)
- chat_id = query.message.chat_id
-
- context.user_data["bots_state"] = "gs_wizard_input"
- context.user_data["gs_wizard_step"] = "edit_tp"
- current_tp = config.get("triple_barrier_config", {}).get("take_profit", 0.0001)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ side = "📈 LONG" if config.get("side") == SIDE_LONG else "📉 SHORT"
keyboard = [
- [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")],
+ [
+ InlineKeyboardButton("1x", callback_data="bots:gs_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:gs_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:gs_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:gs_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:gs_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:gs_leverage:75"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:gs_back_to_side"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
]
- try:
- await query.message.delete()
- except:
- pass
-
- msg = await context.bot.send_message(
- chat_id=chat_id,
- text=r"*Edit Take Profit*" + "\n\n"
- f"Current: `{current_tp*100:.4f}%`" + "\n\n"
- r"Enter new TP \(e\.g\. 0\.03 for 0\.03%\):",
+ # Leverage step is only shown for perps (always 6 steps)
+ await query.message.edit_text(
+ r"*📈 Grid Strike \- Step 4/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}` \\| {side}" + "\n\n"
+ r"⚡ *Select Leverage*",
parse_mode="MarkdownV2",
reply_markup=InlineKeyboardMarkup(keyboard)
)
- context.user_data["gs_wizard_message_id"] = msg.message_id
-async def handle_gs_edit_act(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Edit activation bounds"""
+async def handle_gs_wizard_leverage(update: Update, context: ContextTypes.DEFAULT_TYPE, leverage: int) -> None:
+ """Handle leverage selection in wizard"""
query = update.callback_query
config = get_controller_config(context)
- chat_id = query.message.chat_id
-
- context.user_data["bots_state"] = "gs_wizard_input"
- context.user_data["gs_wizard_step"] = "edit_act"
-
- current_act = config.get("activation_bounds", 0.01)
-
- keyboard = [
- [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")],
- ]
- try:
- await query.message.delete()
- except:
- pass
+ config["leverage"] = leverage
+ set_controller_config(context, config)
- msg = await context.bot.send_message(
- chat_id=chat_id,
- text=r"*Edit Activation Bounds*" + "\n\n"
- f"Current: `{current_act*100:.1f}%`" + "\n\n"
- r"Enter new value \(e\.g\. 1 for 1%\):",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- context.user_data["gs_wizard_message_id"] = msg.message_id
+ # Move to amount step
+ context.user_data["gs_wizard_step"] = "total_amount_quote"
+ await _show_wizard_amount_step(update, context)
-async def handle_gs_edit_max_orders(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Edit max open orders"""
+async def _show_wizard_amount_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Wizard Step 5: Enter Amount with available balances"""
query = update.callback_query
+ chat_id = update.effective_chat.id
config = get_controller_config(context)
- chat_id = query.message.chat_id
- context.user_data["bots_state"] = "gs_wizard_input"
- context.user_data["gs_wizard_step"] = "edit_max_orders"
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ side = "📈 LONG" if config.get("side") == SIDE_LONG else "📉 SHORT"
+ leverage = config.get("leverage", 1)
- current = config.get("max_open_orders", 3)
+ context.user_data["bots_state"] = "gs_wizard_input"
+ context.user_data["gs_wizard_step"] = "total_amount_quote"
- keyboard = [
- [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")],
- ]
+ # Extract base and quote tokens from pair
+ base_token, quote_token = "", ""
+ if "-" in pair:
+ base_token, quote_token = pair.split("-", 1)
+ # Fetch balances for the connector
+ balance_text = ""
try:
- await query.message.delete()
- except:
- pass
-
- msg = await context.bot.send_message(
- chat_id=chat_id,
- text=r"*Edit Max Open Orders*" + "\n\n"
- f"Current: `{current}`" + "\n\n"
- r"Enter new value \(integer\):",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- context.user_data["gs_wizard_message_id"] = msg.message_id
-
+ client = await get_bots_client(chat_id)
+ balances = await get_cex_balances(
+ context.user_data, client, "master_account", ttl=30
+ )
-async def handle_gs_edit_batch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Edit max orders per batch"""
- query = update.callback_query
- config = get_controller_config(context)
- chat_id = query.message.chat_id
+ # Try to find connector balances with flexible matching
+ # (binance_perpetual should match binance_perpetual, binance, etc.)
+ connector_balances = []
+ connector_lower = connector.lower()
+ connector_base = connector_lower.replace("_perpetual", "").replace("_spot", "")
+
+ for bal_connector, bal_list in balances.items():
+ bal_lower = bal_connector.lower()
+ bal_base = bal_lower.replace("_perpetual", "").replace("_spot", "")
+ # Match exact, base name, or if one contains the other
+ if bal_lower == connector_lower or bal_base == connector_base:
+ connector_balances = bal_list
+ logger.debug(f"Found balances for {connector} under key {bal_connector}")
+ break
- context.user_data["bots_state"] = "gs_wizard_input"
- context.user_data["gs_wizard_step"] = "edit_batch"
+ if connector_balances:
+ relevant_balances = []
+ for bal in connector_balances:
+ token = bal.get("token", bal.get("asset", ""))
+ # Portfolio API returns 'units' for available balance
+ available = bal.get("units", bal.get("available_balance", bal.get("free", 0)))
+ value_usd = bal.get("value", 0) # USD value if available
+ if token and available:
+ try:
+ available_float = float(available)
+ if available_float > 0:
+ # Show quote token and base token balances
+ if token.upper() in [quote_token.upper(), base_token.upper()]:
+ relevant_balances.append((token, available_float, float(value_usd) if value_usd else None))
+ except (ValueError, TypeError):
+ continue
+
+ if relevant_balances:
+ bal_lines = []
+ for token, available, value_usd in relevant_balances:
+ # Format amount based on size
+ if available >= 1000:
+ amt_str = f"{available:,.0f}"
+ elif available >= 1:
+ amt_str = f"{available:,.2f}"
+ else:
+ amt_str = f"{available:,.6f}"
- current = config.get("max_orders_per_batch", 1)
+ # Add USD value if available
+ if value_usd and value_usd >= 1:
+ bal_lines.append(f"{token}: {amt_str} (${value_usd:,.0f})")
+ else:
+ bal_lines.append(f"{token}: {amt_str}")
+ balance_text = "💼 *Available:* " + " \\| ".join(
+ escape_markdown_v2(b) for b in bal_lines
+ ) + "\n\n"
+ else:
+ # Connector has balances but not the specific tokens for this pair
+ logger.debug(f"Connector {connector} has balances but not {base_token} or {quote_token}")
+ balance_text = f"_No {escape_markdown_v2(quote_token)} balance on {escape_markdown_v2(connector)}_\n\n"
+ elif balances:
+ # Balances exist but not for this connector/pair
+ logger.debug(f"No balances found for connector {connector} with tokens {base_token}/{quote_token}. Available connectors: {list(balances.keys())}")
+ balance_text = f"_No {escape_markdown_v2(quote_token)} balance found_\n\n"
+ else:
+ logger.debug(f"No balances returned from API for connector {connector}")
+ except Exception as e:
+ logger.warning(f"Could not fetch balances for amount step: {e}", exc_info=True)
keyboard = [
- [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")],
+ [
+ InlineKeyboardButton("💵 100", callback_data="bots:gs_amount:100"),
+ InlineKeyboardButton("💵 500", callback_data="bots:gs_amount:500"),
+ InlineKeyboardButton("💵 1000", callback_data="bots:gs_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("💰 2000", callback_data="bots:gs_amount:2000"),
+ InlineKeyboardButton("💰 5000", callback_data="bots:gs_amount:5000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:gs_back_to_leverage"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
]
- try:
- await query.message.delete()
- except:
- pass
+ # Determine step number based on connector type
+ # Perps: Step 5/6 (has leverage step), Spot: Step 4/5 (no leverage step)
+ is_perp = connector.endswith("_perpetual")
+ step_num = 5 if is_perp else 4
+ total_steps = 6 if is_perp else 5
- msg = await context.bot.send_message(
- chat_id=chat_id,
- text=r"*Edit Max Orders Per Batch*" + "\n\n"
- f"Current: `{current}`" + "\n\n"
- r"Enter new value \(integer\):",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
+ message_text = (
+ rf"*📈 Grid Strike \- Step {step_num}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n"
+ f"🎯 {side} \\| ⚡ `{leverage}x`" + "\n\n"
+ + balance_text +
+ r"💰 *Total Amount \(Quote\)*" + "\n\n"
+ r"Select or type amount:"
)
- context.user_data["gs_wizard_message_id"] = msg.message_id
+
+ # Handle both text and photo messages (when going back from chart step)
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ except Exception:
+ # Message is likely a photo - delete it and send new text message
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
-async def handle_gs_edit_min_amt(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Edit min order amount"""
+async def handle_gs_wizard_amount(update: Update, context: ContextTypes.DEFAULT_TYPE, amount: float) -> None:
+ """Handle amount selection in wizard"""
query = update.callback_query
config = get_controller_config(context)
- chat_id = query.message.chat_id
-
- context.user_data["bots_state"] = "gs_wizard_input"
- context.user_data["gs_wizard_step"] = "edit_min_amt"
-
- current = config.get("min_order_amount_quote", 6)
- keyboard = [
- [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")],
- ]
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
- try:
- await query.message.delete()
- except:
- pass
+ pair = config.get("trading_pair", "")
- msg = await context.bot.send_message(
- chat_id=chat_id,
- text=r"*Edit Min Order Amount*" + "\n\n"
- f"Current: `{current}`" + "\n\n"
- r"Enter new value \(e\.g\. 6\):",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
+ # Always show loading indicator immediately since chart generation takes time
+ await query.message.edit_text(
+ r"*📈 Grid Strike \- New Config*" + "\n\n"
+ f"⏳ *Loading chart for* `{escape_markdown_v2(pair)}`\\.\\.\\." + "\n\n"
+ r"_Fetching market data and generating chart\.\.\._",
+ parse_mode="MarkdownV2"
)
- context.user_data["gs_wizard_message_id"] = msg.message_id
+
+ # Move to prices step - this will fetch OHLC and show chart
+ context.user_data["gs_wizard_step"] = "prices"
+ await _show_wizard_prices_step(update, context)
-async def handle_gs_edit_spread(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Edit min spread between orders"""
+async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT_TYPE, interval: str = None) -> None:
+ """Wizard Step 6: Grid Configuration with prices, TP, spread, and grid analysis"""
query = update.callback_query
+ chat_id = update.effective_chat.id
config = get_controller_config(context)
- chat_id = query.message.chat_id
- context.user_data["bots_state"] = "gs_wizard_input"
- context.user_data["gs_wizard_step"] = "edit_spread"
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ side = config.get("side", SIDE_LONG)
+ total_amount = config.get("total_amount_quote", 1000)
- current = config.get("min_spread_between_orders", 0.0002)
+ # Get current interval (default 1m for better NATR calculation)
+ if interval is None:
+ interval = context.user_data.get("gs_chart_interval", "1m")
+ context.user_data["gs_chart_interval"] = interval
- keyboard = [
- [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")],
- ]
+ # Check if we have pre-cached data from background fetch
+ current_price = context.user_data.get("gs_current_price")
+ candles = context.user_data.get("gs_candles")
try:
- await query.message.delete()
- except:
- pass
+ # If no cached data or interval changed, fetch now
+ cached_interval = context.user_data.get("gs_candles_interval", "1m")
+ need_refetch = interval != cached_interval
- msg = await context.bot.send_message(
- chat_id=chat_id,
- text=r"*Edit Min Spread Between Orders*" + "\n\n"
- f"Current: `{current}`" + "\n\n"
- r"Enter new value \(e\.g\. 0\.0002\):",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- context.user_data["gs_wizard_message_id"] = msg.message_id
+ if not current_price or need_refetch:
+ # Show loading message - handle both text and photo messages
+ try:
+ await query.message.edit_text(
+ r"*📈 Grid Strike \- New Config*" + "\n\n"
+ f"⏳ Fetching market data for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2"
+ )
+ except Exception:
+ # Message is likely a photo - delete it and send new text message
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ loading_msg = await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=(
+ r"*📈 Grid Strike \- New Config*" + "\n\n"
+ f"⏳ Fetching market data for `{escape_markdown_v2(pair)}`\\.\\.\\."
+ ),
+ parse_mode="MarkdownV2"
+ )
+ context.user_data["gs_wizard_message_id"] = loading_msg.message_id
+ client = await get_bots_client(chat_id)
+ current_price = await fetch_current_price(client, connector, pair)
-async def handle_gs_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Save the Grid Strike configuration"""
- query = update.callback_query
- config = get_controller_config(context)
+ if current_price:
+ context.user_data["gs_current_price"] = current_price
+ # Fetch candles for NATR calculation and chart visualization
+ candles = await fetch_candles(client, connector, pair, interval=interval, max_records=420)
+ context.user_data["gs_candles"] = candles
+ context.user_data["gs_candles_interval"] = interval
- config_id = config.get("id", "")
- chat_id = query.message.chat_id
+ # Fetch trading rules for validation
+ try:
+ rules = await get_trading_rules(context.user_data, client, connector)
+ context.user_data["gs_trading_rules"] = rules.get(pair, {})
+ except Exception as e:
+ logger.warning(f"Could not fetch trading rules: {e}")
+ context.user_data["gs_trading_rules"] = {}
- # Delete the current message (could be photo or text)
- try:
- await query.message.delete()
- except:
- pass
+ if not current_price:
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]]
+ try:
+ await query.message.edit_text(
+ r"*❌ Error*" + "\n\n"
+ f"Could not fetch price for `{escape_markdown_v2(pair)}`\\.\n"
+ r"Please check the trading pair and try again\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ except Exception:
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=(
+ r"*❌ Error*" + "\n\n"
+ f"Could not fetch price for `{escape_markdown_v2(pair)}`\\.\n"
+ r"Please check the trading pair and try again\\."
+ ),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ return
- # Send saving status
- status_msg = await context.bot.send_message(
- chat_id=chat_id,
- text=f"Saving configuration `{escape_markdown_v2(config_id)}`\\.\\.\\.",
- parse_mode="MarkdownV2"
- )
+ # Calculate NATR from candles
+ natr = None
+ candles_list = candles.get("data", []) if isinstance(candles, dict) else candles
+ logger.info(f"Candles for {pair} ({interval}): {len(candles_list) if candles_list else 0} records")
+ if candles_list:
+ natr = calculate_natr(candles_list, period=14)
+ context.user_data["gs_natr"] = natr
- try:
- client = await get_bots_client()
- result = await client.controllers.create_or_update_controller_config(config_id, config)
+ # Get trading rules
+ trading_rules = context.user_data.get("gs_trading_rules", {})
+ min_notional = trading_rules.get("min_notional_size", 5.0)
+ min_order_size = trading_rules.get("min_order_size", 0)
- # Clean up wizard state
- _cleanup_wizard_state(context)
+ # Calculate smart defaults based on NATR if not already set
+ if not config.get("start_price") or not config.get("end_price"):
+ if natr and natr > 0:
+ # Use NATR-based suggestions
+ suggestions = suggest_grid_params(
+ current_price, natr, side, total_amount, min_notional
+ )
+ config["start_price"] = suggestions["start_price"]
+ config["end_price"] = suggestions["end_price"]
+ config["limit_price"] = suggestions["limit_price"]
+ # Note: min_spread_between_orders and take_profit use fixed defaults from config.py
+ # NATR-based suggestions are not applied - user prefers consistent defaults
+ else:
+ # Fallback to default percentages
+ start, end, limit = calculate_auto_prices(current_price, side)
+ config["start_price"] = start
+ config["end_price"] = end
+ config["limit_price"] = limit
+
+ start = config.get("start_price")
+ end = config.get("end_price")
+ limit = config.get("limit_price")
+ min_spread = config.get("min_spread_between_orders", 0.0001)
+ take_profit = config.get("triple_barrier_config", {}).get("take_profit", 0.0001)
+ min_order_amount = config.get("min_order_amount_quote", max(6, min_notional))
+
+ # Ensure min_order_amount respects exchange rules
+ if min_notional > min_order_amount:
+ config["min_order_amount_quote"] = min_notional
+ min_order_amount = min_notional
- keyboard = [
- [InlineKeyboardButton("Create Another", callback_data="bots:new_grid_strike")],
- [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
- ]
+ # Generate config ID with sequence number (if not already set)
+ if not config.get("id"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ config["id"] = generate_config_id(connector, pair, existing_configs=existing_configs)
- await status_msg.edit_text(
- r"*Config Saved\!*" + "\n\n"
- f"Controller `{escape_markdown_v2(config_id)}` saved successfully\\.",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
+ set_controller_config(context, config)
+
+ # Generate theoretical grid
+ grid = generate_theoretical_grid(
+ start_price=start,
+ end_price=end,
+ min_spread=min_spread,
+ total_amount=total_amount,
+ min_order_amount=min_order_amount,
+ current_price=current_price,
+ side=side,
+ trading_rules=trading_rules,
)
+ context.user_data["gs_theoretical_grid"] = grid
+
+ # Show price edit options
+ side_str = "📈 LONG" if side == SIDE_LONG else "📉 SHORT"
+
+ context.user_data["bots_state"] = "gs_wizard_input"
+ context.user_data["gs_wizard_step"] = "prices"
+
+ # Build interval buttons with current one highlighted
+ interval_options = ["1m", "5m", "15m", "1h", "4h"]
+ interval_row = []
+ for opt in interval_options:
+ label = f"✓ {opt}" if opt == interval else opt
+ interval_row.append(InlineKeyboardButton(label, callback_data=f"bots:gs_interval:{opt}"))
- except Exception as e:
- logger.error(f"Error saving config: {e}", exc_info=True)
keyboard = [
- [InlineKeyboardButton("Try Again", callback_data="bots:gs_save")],
- [InlineKeyboardButton("Back", callback_data="bots:gs_review_back")],
+ interval_row,
+ [
+ InlineKeyboardButton("💾 Save Config", callback_data="bots:gs_save"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:gs_back_to_amount"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
]
- await status_msg.edit_text(
- format_error_message(f"Failed to save: {str(e)}"),
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
+ # Get config values
+ max_open_orders = config.get("max_open_orders", 3)
+ order_frequency = config.get("order_frequency", 3)
+ leverage = config.get("leverage", 1)
+ side_value = config.get("side", SIDE_LONG)
+ side_str_label = "LONG" if side_value == SIDE_LONG else "SHORT"
-async def handle_gs_review_back(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Go back to review step"""
- await _show_wizard_review_step(update, context)
+ # Grid analysis info
+ grid_valid = "✓" if grid.get("valid") else "⚠️"
+ natr_pct = f"{natr*100:.2f}%" if natr else "N/A"
+ range_pct = f"{grid.get('grid_range_pct', 0):.2f}%"
+ # Determine final step number based on connector type
+ is_perp = connector.endswith("_perpetual")
+ final_step = 6 if is_perp else 5
-def _cleanup_wizard_state(context) -> None:
- """Clean up wizard-related state"""
- keys_to_remove = [
- "gs_wizard_step", "gs_wizard_message_id", "gs_wizard_chat_id",
- "gs_current_price", "gs_candles", "gs_chart_message_id",
- "gs_market_data_ready", "gs_market_data_error",
- "gs_chart_interval", "gs_candles_interval"
- ]
- for key in keys_to_remove:
- context.user_data.pop(key, None)
- clear_bots_state(context)
-
-
-async def _background_fetch_market_data(context, config: dict) -> None:
- """Background task to fetch market data while user continues with wizard"""
- connector = config.get("connector_name", "")
- pair = config.get("trading_pair", "")
+ # Build config text with individually copyable key=value params
+ config_text = (
+ rf"*📈 Grid Strike \- Step {final_step}/{final_step} \(Final\)*" + "\n\n"
+ f"*{escape_markdown_v2(pair)}* {side_str_label}\n"
+ f"Price: `{current_price:,.6g}` \\| Range: `{range_pct}` \\| NATR: `{natr_pct}`\n\n"
+ f"`total_amount_quote={total_amount:.0f}`\n"
+ f"`start_price={start:.6g}`\n"
+ f"`end_price={end:.6g}`\n"
+ f"`limit_price={limit:.6g}`\n"
+ f"`leverage={leverage}`\n"
+ f"`take_profit={take_profit}`\n"
+ f"`min_spread_between_orders={min_spread}`\n"
+ f"`min_order_amount_quote={min_order_amount:.0f}`\n"
+ f"`max_open_orders={max_open_orders}`\n\n"
+ f"{grid_valid} Grid: `{grid['num_levels']}` levels "
+ f"\\(↓{grid.get('levels_below_current', 0)} ↑{grid.get('levels_above_current', 0)}\\) "
+ f"@ `${grid['amount_per_level']:.2f}`/lvl"
+ )
- if not connector or not pair:
- return
+ # Add warnings if any
+ if grid.get("warnings"):
+ warnings_text = "\n".join(f"⚠️ {escape_markdown_v2(w)}" for w in grid["warnings"])
+ config_text += f"\n{warnings_text}"
- try:
- client = await get_bots_client()
+ config_text += "\n\n_Edit: `field=value`_"
- # Fetch current price
- current_price = await fetch_current_price(client, connector, pair)
+ # Generate chart and send as photo with caption
+ if candles_list:
+ chart_bytes = generate_candles_chart(
+ candles_list, pair,
+ start_price=start,
+ end_price=end,
+ limit_price=limit,
+ current_price=current_price,
+ side=side
+ )
- if current_price:
- context.user_data["gs_current_price"] = current_price
+ # Delete old message and send photo with caption + buttons
+ try:
+ await query.message.delete()
+ except:
+ pass
- # Fetch candles (5m, 2000 records)
- candles = await fetch_candles(client, connector, pair, interval="5m", max_records=2000)
- context.user_data["gs_candles"] = candles
- context.user_data["gs_market_data_ready"] = True
+ msg = await context.bot.send_photo(
+ chat_id=query.message.chat_id,
+ photo=chart_bytes,
+ caption=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- logger.info(f"Background fetch complete for {pair}: price={current_price}")
+ context.user_data["gs_wizard_message_id"] = msg.message_id
+ context.user_data["gs_wizard_chat_id"] = query.message.chat_id
else:
- context.user_data["gs_market_data_error"] = f"Could not fetch price for {pair}"
+ # No chart - handle photo messages
+ if getattr(query.message, 'photo', None):
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ msg = await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ context.user_data["gs_wizard_message_id"] = msg.message_id
+ else:
+ await query.message.edit_text(
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ context.user_data["gs_wizard_message_id"] = query.message.message_id
except Exception as e:
- logger.error(f"Background fetch error for {pair}: {e}")
- context.user_data["gs_market_data_error"] = str(e)
-
-
-async def process_gs_wizard_input(update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str) -> None:
- """Process text input during wizard flow"""
- step = context.user_data.get("gs_wizard_step")
- config = get_controller_config(context)
-
- if not step:
- return
-
- try:
- # Delete user's message
+ logger.error(f"Error in prices step: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ error_msg = format_error_message(f"Error fetching market data: {str(e)}")
try:
- await update.message.delete()
- except:
+ if getattr(query.message, 'photo', None):
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ await query.message.chat.send_message(
+ error_msg,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ else:
+ await query.message.edit_text(
+ error_msg,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ except Exception:
pass
- if step == "trading_pair":
- # Validate and set trading pair
- pair = user_input.upper().strip()
- if "-" not in pair:
- pair = pair.replace("/", "-").replace("_", "-")
-
- config["trading_pair"] = pair
- set_controller_config(context, config)
-
- # Start background fetch of market data
- asyncio.create_task(_background_fetch_market_data(context, config))
- # Move to side step
- context.user_data["gs_wizard_step"] = "side"
+async def handle_gs_accept_prices(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Accept grid configuration and save - legacy handler, redirects to gs_save"""
+ # Redirect to save handler since prices step is now the final step
+ await handle_gs_save(update, context)
- # Update the wizard message
- await _update_wizard_message_for_side(update, context)
- elif step == "prices":
- # Parse comma-separated prices: start,end,limit
- parts = user_input.replace(" ", "").split(",")
- if len(parts) == 3:
- config["start_price"] = float(parts[0])
- config["end_price"] = float(parts[1])
- config["limit_price"] = float(parts[2])
- set_controller_config(context, config)
- # Stay in prices step to show updated values
- await _update_wizard_message_for_prices_after_edit(update, context)
- elif len(parts) == 1:
- # Single price - ask which one to update
- raise ValueError("Use format: start,end,limit")
- else:
- raise ValueError("Invalid format")
+async def handle_gs_back_to_prices(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Go back to prices step from validation error"""
+ context.user_data["gs_wizard_step"] = "prices"
+ await _show_wizard_prices_step(update, context)
- elif step == "take_profit":
- # Parse take profit - interpret as percentage (0.4 = 0.4% = 0.004)
- tp_input = user_input.replace("%", "").strip()
- tp_pct = float(tp_input)
- tp_decimal = tp_pct / 100 # Convert 0.4 -> 0.004
- config = get_controller_config(context)
- if "triple_barrier_config" not in config:
- config["triple_barrier_config"] = GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy()
- config["triple_barrier_config"]["take_profit"] = tp_decimal
- set_controller_config(context, config)
+async def handle_gs_back_to_connector(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Go back to connector selection step"""
+ context.user_data["gs_wizard_step"] = "connector_name"
+ await _show_wizard_connector_step(update, context)
- # Move to review step
- context.user_data["gs_wizard_step"] = "review"
- await _update_wizard_message_for_review(update, context)
- elif step == "total_amount_quote":
- amount = float(user_input)
- config["total_amount_quote"] = amount
- set_controller_config(context, config)
+async def handle_gs_back_to_pair(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Go back to trading pair step"""
+ context.user_data["gs_wizard_step"] = "trading_pair"
+ await _show_wizard_pair_step(update, context)
- # Move to prices step
- context.user_data["gs_wizard_step"] = "prices"
- await _update_wizard_message_for_prices(update, context)
- elif step == "edit_id":
- new_id = user_input.strip()
- config["id"] = new_id
- set_controller_config(context, config)
+async def handle_gs_back_to_side(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Go back to side selection step"""
+ context.user_data["gs_wizard_step"] = "side"
+ await _show_wizard_side_step(update, context)
- # Save immediately
- context.user_data["gs_wizard_step"] = "review"
- await _trigger_gs_save(update, context)
- elif step in ["start_price", "end_price", "limit_price"]:
- price = float(user_input)
- price_field = step.replace("_price", "_price")
- config[step] = price
- set_controller_config(context, config)
+async def handle_gs_back_to_leverage(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Go back to leverage step (or side step for spot exchanges)"""
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
- # Go back to prices step
- context.user_data["gs_wizard_step"] = "prices"
- await _update_wizard_message_for_prices_after_edit(update, context)
+ # If spot exchange, go back to side step instead
+ if not connector.endswith("_perpetual"):
+ context.user_data["gs_wizard_step"] = "side"
+ await _show_wizard_side_step(update, context)
+ else:
+ context.user_data["gs_wizard_step"] = "leverage"
+ await _show_wizard_leverage_step(update, context)
- elif step == "edit_tp":
- tp_input = user_input.replace("%", "").strip()
- tp_pct = float(tp_input)
- tp_decimal = tp_pct / 100 # Convert 0.03 -> 0.0003
- if "triple_barrier_config" not in config:
- config["triple_barrier_config"] = GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy()
- config["triple_barrier_config"]["take_profit"] = tp_decimal
- set_controller_config(context, config)
- context.user_data["gs_wizard_step"] = "review"
- await _update_wizard_message_for_review(update, context)
- elif step == "edit_act":
- act_input = user_input.replace("%", "").strip()
- act_pct = float(act_input)
- act_decimal = act_pct / 100 # Convert 1 -> 0.01
- config["activation_bounds"] = act_decimal
- set_controller_config(context, config)
- context.user_data["gs_wizard_step"] = "review"
- await _update_wizard_message_for_review(update, context)
+async def handle_gs_back_to_amount(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Go back to amount step"""
+ context.user_data["gs_wizard_step"] = "total_amount_quote"
+ # Clear cached market data to avoid showing stale chart
+ context.user_data.pop("gs_current_price", None)
+ context.user_data.pop("gs_candles", None)
+ await _show_wizard_amount_step(update, context)
- elif step == "edit_max_orders":
- config["max_open_orders"] = int(user_input)
- set_controller_config(context, config)
- context.user_data["gs_wizard_step"] = "review"
- await _update_wizard_message_for_review(update, context)
- elif step == "edit_batch":
- config["max_orders_per_batch"] = int(user_input)
- set_controller_config(context, config)
- context.user_data["gs_wizard_step"] = "review"
- await _update_wizard_message_for_review(update, context)
+async def handle_gs_interval_change(update: Update, context: ContextTypes.DEFAULT_TYPE, interval: str) -> None:
+ """Handle interval change for chart - refetch candles with new interval"""
+ query = update.callback_query
- elif step == "edit_min_amt":
- config["min_order_amount_quote"] = float(user_input)
- set_controller_config(context, config)
- context.user_data["gs_wizard_step"] = "review"
- await _update_wizard_message_for_review(update, context)
+ # Clear cached candles to force refetch
+ context.user_data.pop("gs_candles", None)
+ context.user_data["gs_chart_interval"] = interval
- elif step == "edit_spread":
- config["min_spread_between_orders"] = float(user_input)
- set_controller_config(context, config)
- context.user_data["gs_wizard_step"] = "review"
- await _update_wizard_message_for_review(update, context)
+ # Redisplay prices step with new interval
+ await _show_wizard_prices_step(update, context, interval=interval)
- elif step == "review":
- # Parse field: value or field=value pairs (YAML-style)
- field_map = {
- # Real YAML field names
- "id": "id",
- "connector_name": "connector_name",
- "trading_pair": "trading_pair",
- "side": "side",
- "leverage": "leverage",
- "total_amount_quote": "total_amount_quote",
- "start_price": "start_price",
- "end_price": "end_price",
- "limit_price": "limit_price",
- "take_profit": "triple_barrier_config.take_profit",
- "keep_position": "keep_position",
- "activation_bounds": "activation_bounds",
- "max_open_orders": "max_open_orders",
- "max_orders_per_batch": "max_orders_per_batch",
- "min_order_amount_quote": "min_order_amount_quote",
- "min_spread_between_orders": "min_spread_between_orders",
- }
- updated_fields = []
- lines = user_input.strip().split("\n")
- for line in lines:
- line = line.strip()
- # Support both YAML style (field: value) and equals style (field=value)
- if ":" in line:
- key, value = line.split(":", 1)
- elif "=" in line:
- key, value = line.split("=", 1)
- else:
- continue
- key = key.strip().lower()
- value = value.strip()
+async def _show_wizard_take_profit_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Wizard Step 7: Take Profit Configuration"""
+ query = update.callback_query
+ config = get_controller_config(context)
- if key not in field_map:
- continue
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ side = "📈 LONG" if config.get("side") == SIDE_LONG else "📉 SHORT"
- field = field_map[key]
-
- # Handle special cases
- if key == "side":
- # Accept both numeric (1, 2) and text (LONG, SHORT)
- if value in ("1", "LONG", "long"):
- config["side"] = SIDE_LONG
- else:
- config["side"] = SIDE_SHORT
- elif key == "keep_position":
- config["keep_position"] = value.lower() in ("true", "yes", "y", "1")
- elif key == "take_profit":
- if "triple_barrier_config" not in config:
- config["triple_barrier_config"] = GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy()
- config["triple_barrier_config"]["take_profit"] = float(value)
- elif field in ["leverage", "max_open_orders", "max_orders_per_batch"]:
- config[field] = int(value)
- elif field in ["total_amount_quote", "start_price", "end_price", "limit_price",
- "activation_bounds", "min_order_amount_quote", "min_spread_between_orders"]:
- config[field] = float(value)
- else:
- config[field] = value
-
- updated_fields.append(key)
-
- if updated_fields:
- set_controller_config(context, config)
- await _update_wizard_message_for_review(update, context)
- else:
- raise ValueError("No valid fields found")
-
- except ValueError:
- # Send error and let user try again
- error_msg = await update.message.reply_text(
- f"Invalid input. Please enter a valid value."
- )
- # Auto-delete error after 3 seconds
- await asyncio.sleep(3)
- try:
- await error_msg.delete()
- except:
- pass
-
-
-async def _update_wizard_message_for_side(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Update wizard message to show side step after pair input"""
- config = get_controller_config(context)
- message_id = context.user_data.get("gs_wizard_message_id")
- chat_id = context.user_data.get("gs_wizard_chat_id")
-
- if not message_id or not chat_id:
- return
-
- connector = config.get("connector_name", "")
- pair = config.get("trading_pair", "")
+ context.user_data["bots_state"] = "gs_wizard_input"
+ context.user_data["gs_wizard_step"] = "take_profit"
keyboard = [
[
- InlineKeyboardButton("📈 LONG", callback_data="bots:gs_side:long"),
- InlineKeyboardButton("📉 SHORT", callback_data="bots:gs_side:short"),
+ InlineKeyboardButton("0.01%", callback_data="bots:gs_tp:0.0001"),
+ InlineKeyboardButton("0.02%", callback_data="bots:gs_tp:0.0002"),
+ InlineKeyboardButton("0.05%", callback_data="bots:gs_tp:0.0005"),
+ ],
+ [
+ InlineKeyboardButton("0.1%", callback_data="bots:gs_tp:0.001"),
+ InlineKeyboardButton("0.2%", callback_data="bots:gs_tp:0.002"),
+ InlineKeyboardButton("0.5%", callback_data="bots:gs_tp:0.005"),
],
- [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
]
- try:
- await context.bot.edit_message_text(
- chat_id=chat_id,
- message_id=message_id,
- text=(
- r"*📈 Grid Strike \- New Config*" + "\n\n"
- f"🏦 *Connector:* `{escape_markdown_v2(connector)}`" + "\n"
- f"🔗 *Pair:* `{escape_markdown_v2(pair)}`" + "\n\n"
- r"*Step 3/7:* 🎯 Side" + "\n\n"
- r"Select trading side:"
- ),
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- except Exception as e:
- logger.error(f"Error updating wizard message: {e}")
-
+ message_text = (
+ r"*📈 Grid Strike \- New Config*" + "\n\n"
+ f"🏦 *Connector:* `{escape_markdown_v2(connector)}`" + "\n"
+ f"🔗 *Pair:* `{escape_markdown_v2(pair)}`" + "\n"
+ f"🎯 *Side:* `{side}` \\| ⚡ *Leverage:* `{config.get('leverage', 1)}x`" + "\n"
+ f"💰 *Amount:* `{config.get('total_amount_quote', 0):,.0f}`" + "\n"
+ f"📊 *Grid:* `{config.get('start_price', 0):,.6g}` \\- `{config.get('end_price', 0):,.6g}`" + "\n\n"
+ r"*Step 7/7:* 🎯 Take Profit" + "\n\n"
+ r"Select or type take profit % \(e\.g\. `0\.4` for 0\.4%\):"
+ )
-async def _update_wizard_message_for_prices(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Trigger prices step after amount input"""
- message_id = context.user_data.get("gs_wizard_message_id")
- chat_id = context.user_data.get("gs_wizard_chat_id")
+ # Delete photo message and send text message
+ try:
+ await query.message.delete()
+ except:
+ pass
- if not message_id or not chat_id:
- return
+ msg = await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- # Create a fake query object to reuse _show_wizard_prices_step
- class FakeQuery:
- def __init__(self, bot, chat_id, message_id):
- self.message = FakeMessage(bot, chat_id, message_id)
+ context.user_data["gs_wizard_message_id"] = msg.message_id
+ context.user_data["gs_wizard_chat_id"] = query.message.chat_id
- class FakeMessage:
- def __init__(self, bot, chat_id, message_id):
- self.chat_id = chat_id
- self.message_id = message_id
- self._bot = bot
- async def edit_text(self, text, **kwargs):
- await self._bot.edit_message_text(
- chat_id=self.chat_id,
- message_id=self.message_id,
- text=text,
- **kwargs
- )
+async def handle_gs_wizard_take_profit(update: Update, context: ContextTypes.DEFAULT_TYPE, tp: float) -> None:
+ """Handle take profit selection and show final review"""
+ query = update.callback_query
+ config = get_controller_config(context)
- async def delete(self):
- await self._bot.delete_message(chat_id=self.chat_id, message_id=self.message_id)
+ if "triple_barrier_config" not in config:
+ config["triple_barrier_config"] = GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy()
+ config["triple_barrier_config"]["take_profit"] = tp
+ set_controller_config(context, config)
- fake_update = type('FakeUpdate', (), {'callback_query': FakeQuery(context.bot, chat_id, message_id)})()
- await _show_wizard_prices_step(fake_update, context)
+ # Move to review step
+ context.user_data["gs_wizard_step"] = "review"
+ await _show_wizard_review_step(update, context)
-async def _update_wizard_message_for_prices_after_edit(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Update prices display after editing prices - regenerate chart with new prices"""
+async def _show_wizard_review_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Final Review Step with copyable config format"""
+ query = update.callback_query
config = get_controller_config(context)
- message_id = context.user_data.get("gs_wizard_message_id")
- chat_id = context.user_data.get("gs_wizard_chat_id")
-
- if not message_id or not chat_id:
- return
connector = config.get("connector_name", "")
pair = config.get("trading_pair", "")
- side = config.get("side", SIDE_LONG)
- side_str = "📈 LONG" if side == SIDE_LONG else "📉 SHORT"
- start = config.get("start_price", 0)
- end = config.get("end_price", 0)
- limit = config.get("limit_price", 0)
- current_price = context.user_data.get("gs_current_price", 0)
- candles = context.user_data.get("gs_candles")
- interval = context.user_data.get("gs_chart_interval", "5m")
+ side = "LONG" if config.get("side") == SIDE_LONG else "SHORT"
+ leverage = config.get("leverage", 1)
+ amount = config.get("total_amount_quote", 0)
+ start_price = config.get("start_price", 0)
+ end_price = config.get("end_price", 0)
+ limit_price = config.get("limit_price", 0)
+ tp = config.get("triple_barrier_config", {}).get("take_profit", 0.0001)
+ keep_position = config.get("keep_position", True)
+ activation_bounds = config.get("activation_bounds", 0.01)
+ config_id = config.get("id", "")
+ max_open_orders = config.get("max_open_orders", 3)
+ max_orders_per_batch = config.get("max_orders_per_batch", 1)
+ min_order_amount = config.get("min_order_amount_quote", 6)
+ min_spread = config.get("min_spread_between_orders", 0.0001)
- # Build interval buttons with current one highlighted
- interval_options = ["1m", "5m", "15m", "1h", "4h"]
- interval_row = []
- for opt in interval_options:
- label = f"✓ {opt}" if opt == interval else opt
- interval_row.append(InlineKeyboardButton(label, callback_data=f"bots:gs_interval:{opt}"))
+ # Delete previous chart if exists
+ chart_msg_id = context.user_data.pop("gs_chart_message_id", None)
+ if chart_msg_id:
+ try:
+ await context.bot.delete_message(
+ chat_id=query.message.chat_id,
+ message_id=chart_msg_id
+ )
+ except:
+ pass
+
+ context.user_data["bots_state"] = "gs_wizard_input"
+ context.user_data["gs_wizard_step"] = "review"
+
+ # Build copyable config block with real YAML field names
+ side_value = config.get("side", SIDE_LONG)
+ config_block = (
+ f"id: {config_id}\n"
+ f"connector_name: {connector}\n"
+ f"trading_pair: {pair}\n"
+ f"side: {side_value}\n"
+ f"leverage: {leverage}\n"
+ f"total_amount_quote: {amount:.0f}\n"
+ f"start_price: {start_price:.6g}\n"
+ f"end_price: {end_price:.6g}\n"
+ f"limit_price: {limit_price:.6g}\n"
+ f"take_profit: {tp}\n"
+ f"keep_position: {str(keep_position).lower()}\n"
+ f"activation_bounds: {activation_bounds}\n"
+ f"max_open_orders: {max_open_orders}\n"
+ f"max_orders_per_batch: {max_orders_per_batch}\n"
+ f"min_order_amount_quote: {min_order_amount}\n"
+ f"min_spread_between_orders: {min_spread}"
+ )
+
+ message_text = (
+ f"*{escape_markdown_v2(pair)}* \\- Review Config\n\n"
+ f"```\n{config_block}\n```\n\n"
+ f"_To edit, send `field: value` lines:_\n"
+ f"`leverage: 75`\n"
+ f"`total_amount_quote: 1000`"
+ )
keyboard = [
- interval_row,
[
- InlineKeyboardButton("✅ Accept Prices", callback_data="bots:gs_accept_prices"),
+ InlineKeyboardButton("✅ Save Config", callback_data="bots:gs_save"),
+ ],
+ [
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
],
- [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")],
]
- # Format example with current values
- example_prices = f"{start:,.6g},{end:,.6g},{limit:,.6g}"
-
- # Build the caption
- config_text = (
- f"*📊 {escape_markdown_v2(pair)}* \\- Grid Zone Preview\n\n"
- f"🏦 *Connector:* `{escape_markdown_v2(connector)}`\n"
- f"🎯 *Side:* `{side_str}` \\| ⚡ *Leverage:* `{config.get('leverage', 1)}x`\n"
- f"💰 *Amount:* `{config.get('total_amount_quote', 0):,.0f}`\n\n"
- f"📍 Current: `{current_price:,.6g}`\n"
- f"🟢 Start: `{start:,.6g}`\n"
- f"🔵 End: `{end:,.6g}`\n"
- f"🔴 Limit: `{limit:,.6g}`\n\n"
- f"_Type `start,end,limit` to edit_\n"
- f"_e\\.g\\. `{escape_markdown_v2(example_prices)}`_"
- )
-
+ # Handle photo messages - can't edit_text on photos, need to delete and send new
try:
- # Delete old message (which is a photo)
- try:
- await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
- except Exception:
- pass
-
- # Generate new chart with updated prices
- if candles:
- chart_bytes = generate_candles_chart(
- candles, pair,
- start_price=start,
- end_price=end,
- limit_price=limit,
- current_price=current_price
- )
-
- # Send new photo with updated caption
- msg = await context.bot.send_photo(
- chat_id=chat_id,
- photo=chart_bytes,
- caption=config_text,
+ if getattr(query.message, 'photo', None):
+ await query.message.delete()
+ msg = await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
parse_mode="MarkdownV2",
reply_markup=InlineKeyboardMarkup(keyboard)
)
-
- # Update stored message ID
context.user_data["gs_wizard_message_id"] = msg.message_id
else:
- # No chart - send text message
- msg = await context.bot.send_message(
- chat_id=chat_id,
- text=config_text,
+ await query.message.edit_text(
+ message_text,
parse_mode="MarkdownV2",
reply_markup=InlineKeyboardMarkup(keyboard)
)
- context.user_data["gs_wizard_message_id"] = msg.message_id
+ except BadRequest as e:
+ # Fallback: delete and send new message
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ msg = await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ context.user_data["gs_wizard_message_id"] = msg.message_id
- except Exception as e:
- logger.error(f"Error updating prices message: {e}", exc_info=True)
-
-
-async def handle_gs_edit_price(update: Update, context: ContextTypes.DEFAULT_TYPE, price_type: str) -> None:
- """Handle price editing request"""
- query = update.callback_query
- config = get_controller_config(context)
-
- price_map = {
- "start": ("start_price", "Start Price"),
- "end": ("end_price", "End Price"),
- "limit": ("limit_price", "Limit Price"),
- }
-
- field, label = price_map.get(price_type, ("start_price", "Start Price"))
- current = config.get(field, 0)
- context.user_data["bots_state"] = "gs_wizard_input"
- context.user_data["gs_wizard_step"] = field
-
- keyboard = [[InlineKeyboardButton("Cancel", callback_data="bots:gs_accept_prices")]]
-
- await query.message.edit_text(
- f"*Edit {escape_markdown_v2(label)}*" + "\n\n"
- f"Current: `{current:,.6g}`" + "\n\n"
- r"Enter new price:",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
-
-
-async def _trigger_gs_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Trigger save after ID edit"""
+async def _update_wizard_message_for_review(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Update wizard to show review step with copyable config format"""
message_id = context.user_data.get("gs_wizard_message_id")
chat_id = context.user_data.get("gs_wizard_chat_id")
if not message_id or not chat_id:
return
- class FakeQuery:
- def __init__(self, bot, chat_id, message_id):
- self.message = FakeMessage(bot, chat_id, message_id)
+ config = get_controller_config(context)
- class FakeMessage:
- def __init__(self, bot, chat_id, message_id):
- self.chat_id = chat_id
- self.message_id = message_id
- self._bot = bot
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ side = "LONG" if config.get("side") == SIDE_LONG else "SHORT"
+ leverage = config.get("leverage", 1)
+ amount = config.get("total_amount_quote", 0)
+ start_price = config.get("start_price", 0)
+ end_price = config.get("end_price", 0)
+ limit_price = config.get("limit_price", 0)
+ tp = config.get("triple_barrier_config", {}).get("take_profit", 0.0001)
+ keep_position = config.get("keep_position", True)
+ activation_bounds = config.get("activation_bounds", 0.01)
+ config_id = config.get("id", "")
+ max_open_orders = config.get("max_open_orders", 3)
+ max_orders_per_batch = config.get("max_orders_per_batch", 1)
+ min_order_amount = config.get("min_order_amount_quote", 6)
+ min_spread = config.get("min_spread_between_orders", 0.0001)
- async def edit_text(self, text, **kwargs):
- await self._bot.edit_message_text(
- chat_id=self.chat_id,
- message_id=self.message_id,
- text=text,
- **kwargs
- )
+ # Build copyable config block with real YAML field names
+ side_value = config.get("side", SIDE_LONG)
+ config_block = (
+ f"id: {config_id}\n"
+ f"connector_name: {connector}\n"
+ f"trading_pair: {pair}\n"
+ f"side: {side_value}\n"
+ f"leverage: {leverage}\n"
+ f"total_amount_quote: {amount:.0f}\n"
+ f"start_price: {start_price:.6g}\n"
+ f"end_price: {end_price:.6g}\n"
+ f"limit_price: {limit_price:.6g}\n"
+ f"take_profit: {tp}\n"
+ f"keep_position: {str(keep_position).lower()}\n"
+ f"activation_bounds: {activation_bounds}\n"
+ f"max_open_orders: {max_open_orders}\n"
+ f"max_orders_per_batch: {max_orders_per_batch}\n"
+ f"min_order_amount_quote: {min_order_amount}\n"
+ f"min_spread_between_orders: {min_spread}"
+ )
- fake_update = type('FakeUpdate', (), {'callback_query': FakeQuery(context.bot, chat_id, message_id)})()
- await handle_gs_save(fake_update, context)
+ message_text = (
+ f"*{escape_markdown_v2(pair)}* \\- Review Config\n\n"
+ f"```\n{config_block}\n```\n\n"
+ f"_To edit, send `field: value` lines:_\n"
+ f"`leverage: 75`\n"
+ f"`total_amount_quote: 1000`"
+ )
+
+ keyboard = [
+ [
+ InlineKeyboardButton("✅ Save Config", callback_data="bots:gs_save"),
+ ],
+ [
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+ try:
+ await context.bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ except Exception as e:
+ logger.error(f"Error updating review message: {e}")
-# ============================================
-# LEGACY FORM (for edit mode)
-# ============================================
-async def show_config_form(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Show the configuration form with current values (legacy/edit mode)"""
+async def handle_gs_edit_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Allow user to edit config ID before saving"""
query = update.callback_query
config = get_controller_config(context)
+ chat_id = query.message.chat_id
- if not config:
- config = init_new_controller_config(context, "grid_strike")
+ context.user_data["bots_state"] = "gs_wizard_input"
+ context.user_data["gs_wizard_step"] = "edit_id"
- # Build the form display
- lines = [r"*Grid Strike Configuration*", ""]
+ current_id = config.get("id", "")
- # Show current values
- for field_name in GRID_STRIKE_FIELD_ORDER:
- field_info = GRID_STRIKE_FIELDS[field_name]
- label = field_info["label"]
+ keyboard = [
+ [InlineKeyboardButton(f"Keep: {current_id[:25]}", callback_data="bots:gs_save")],
+ [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")],
+ ]
- # Get value, handling nested triple_barrier_config
- if field_name == "take_profit":
- value = config.get("triple_barrier_config", {}).get("take_profit", 0.0001)
- elif field_name == "open_order_type":
- value = config.get("triple_barrier_config", {}).get("open_order_type", ORDER_TYPE_LIMIT_MAKER)
- elif field_name == "take_profit_order_type":
- value = config.get("triple_barrier_config", {}).get("take_profit_order_type", ORDER_TYPE_LIMIT_MAKER)
- else:
- value = config.get(field_name, "")
+ # Delete current message (could be photo)
+ try:
+ await query.message.delete()
+ except:
+ pass
- formatted_value = format_config_field_value(field_name, value)
- required = "\\*" if field_info.get("required") else ""
+ msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=r"*Edit Config ID*" + "\n\n"
+ f"Current: `{escape_markdown_v2(current_id)}`" + "\n\n"
+ r"Type a new ID or tap Keep to use current:",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ context.user_data["gs_wizard_message_id"] = msg.message_id
- lines.append(f"*{escape_markdown_v2(label)}*{required}: `{escape_markdown_v2(formatted_value)}`")
- lines.append("")
- lines.append(r"_Tap a button to edit a field\. \* \= required_")
+async def handle_gs_edit_keep(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Toggle keep_position setting"""
+ query = update.callback_query
+ config = get_controller_config(context)
- # Build keyboard with field buttons
- keyboard = []
+ # Toggle the value
+ current = config.get("keep_position", True)
+ config["keep_position"] = not current
+ context.user_data["controller_config"] = config
- # Row 1: ID and Connector
- keyboard.append([
- InlineKeyboardButton("ID", callback_data="bots:set_field:id"),
- InlineKeyboardButton("Connector", callback_data="bots:set_field:connector_name"),
- InlineKeyboardButton("Pair", callback_data="bots:set_field:trading_pair"),
- ])
+ # Go back to review
+ await _show_wizard_review_step(update, context)
- # Row 2: Side and Leverage
- keyboard.append([
- InlineKeyboardButton("Side", callback_data="bots:toggle_side"),
- InlineKeyboardButton("Leverage", callback_data="bots:set_field:leverage"),
- InlineKeyboardButton("Amount", callback_data="bots:set_field:total_amount_quote"),
- ])
- # Row 3: Prices
- keyboard.append([
- InlineKeyboardButton("Start Price", callback_data="bots:set_field:start_price"),
- InlineKeyboardButton("End Price", callback_data="bots:set_field:end_price"),
- InlineKeyboardButton("Limit Price", callback_data="bots:set_field:limit_price"),
- ])
+async def handle_gs_edit_tp(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Edit take profit"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ chat_id = query.message.chat_id
- # Row 4: Advanced
- keyboard.append([
- InlineKeyboardButton("Max Orders", callback_data="bots:set_field:max_open_orders"),
- InlineKeyboardButton("Min Spread", callback_data="bots:set_field:min_spread_between_orders"),
- InlineKeyboardButton("Take Profit", callback_data="bots:set_field:take_profit"),
- ])
+ context.user_data["bots_state"] = "gs_wizard_input"
+ context.user_data["gs_wizard_step"] = "edit_tp"
- # Row 5: Order Types
- keyboard.append([
- InlineKeyboardButton("Open Order Type", callback_data="bots:cycle_order_type:open"),
- InlineKeyboardButton("TP Order Type", callback_data="bots:cycle_order_type:tp"),
- ])
+ current_tp = config.get("triple_barrier_config", {}).get("take_profit", 0.0001)
- # Row 6: Actions
- keyboard.append([
- InlineKeyboardButton("Save Config", callback_data="bots:save_config"),
- InlineKeyboardButton("Cancel", callback_data="bots:controller_configs"),
- ])
+ keyboard = [
+ [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")],
+ ]
- reply_markup = InlineKeyboardMarkup(keyboard)
+ try:
+ await query.message.delete()
+ except:
+ pass
- await query.message.edit_text(
- "\n".join(lines),
+ msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=r"*Edit Take Profit*" + "\n\n"
+ f"Current: `{current_tp*100:.4f}%`" + "\n\n"
+ r"Enter new TP \(e\.g\. 0\.03 for 0\.03%\):",
parse_mode="MarkdownV2",
- reply_markup=reply_markup
+ reply_markup=InlineKeyboardMarkup(keyboard)
)
+ context.user_data["gs_wizard_message_id"] = msg.message_id
-# ============================================
-# FIELD EDITING
-# ============================================
-
-async def handle_set_field(update: Update, context: ContextTypes.DEFAULT_TYPE, field_name: str) -> None:
- """Prompt user to enter a value for a field
-
- Args:
- update: Telegram update
- context: Telegram context
- field_name: Name of the field to edit
- """
+async def handle_gs_edit_act(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Edit activation bounds"""
query = update.callback_query
+ config = get_controller_config(context)
+ chat_id = query.message.chat_id
- # Special handling for connector_name - show button selector
- if field_name == "connector_name":
- await show_connector_selector(update, context)
- return
+ context.user_data["bots_state"] = "gs_wizard_input"
+ context.user_data["gs_wizard_step"] = "edit_act"
- field_info = GRID_STRIKE_FIELDS.get(field_name, {})
- label = field_info.get("label", field_name)
- hint = field_info.get("hint", "")
- field_type = field_info.get("type", "str")
-
- # Set state for text input
- context.user_data["bots_state"] = f"set_field:{field_name}"
- context.user_data["editing_controller_field"] = field_name
+ current_act = config.get("activation_bounds", 0.01)
- # Get current value
- config = get_controller_config(context)
- if field_name == "take_profit":
- current = config.get("triple_barrier_config", {}).get("take_profit", 0.0001)
- else:
- current = config.get(field_name, "")
+ keyboard = [
+ [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")],
+ ]
- current_str = format_config_field_value(field_name, current)
+ try:
+ await query.message.delete()
+ except:
+ pass
- message = (
- f"*Set {escape_markdown_v2(label)}*\n\n"
- f"Current: `{escape_markdown_v2(current_str)}`\n\n"
+ msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=r"*Edit Activation Bounds*" + "\n\n"
+ f"Current: `{current_act*100:.1f}%`" + "\n\n"
+ r"Enter new value \(e\.g\. 1 for 1%\):",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
)
+ context.user_data["gs_wizard_message_id"] = msg.message_id
- if hint:
- message += f"_Hint: {escape_markdown_v2(hint)}_\n\n"
- message += r"Type the new value or tap Cancel\."
+async def handle_gs_edit_max_orders(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Edit max open orders"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ chat_id = query.message.chat_id
- keyboard = [[InlineKeyboardButton("Cancel", callback_data="bots:edit_config_back")]]
- reply_markup = InlineKeyboardMarkup(keyboard)
+ context.user_data["bots_state"] = "gs_wizard_input"
+ context.user_data["gs_wizard_step"] = "edit_max_orders"
- await query.message.edit_text(
- message,
+ current = config.get("max_open_orders", 3)
+
+ keyboard = [
+ [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")],
+ ]
+
+ try:
+ await query.message.delete()
+ except:
+ pass
+
+ msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=r"*Edit Max Open Orders*" + "\n\n"
+ f"Current: `{current}`" + "\n\n"
+ r"Enter new value \(integer\):",
parse_mode="MarkdownV2",
- reply_markup=reply_markup
+ reply_markup=InlineKeyboardMarkup(keyboard)
)
+ context.user_data["gs_wizard_message_id"] = msg.message_id
-# ============================================
-# CONNECTOR SELECTOR
-# ============================================
-
-async def show_connector_selector(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Show connector selection keyboard with available CEX connectors"""
+async def handle_gs_edit_batch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Edit max orders per batch"""
query = update.callback_query
+ config = get_controller_config(context)
+ chat_id = query.message.chat_id
- try:
- client = await get_bots_client()
+ context.user_data["bots_state"] = "gs_wizard_input"
+ context.user_data["gs_wizard_step"] = "edit_batch"
- # Get available CEX connectors (with cache)
- cex_connectors = await get_available_cex_connectors(context.user_data, client)
+ current = config.get("max_orders_per_batch", 1)
- if not cex_connectors:
- await query.answer("No CEX connectors configured", show_alert=True)
- return
+ keyboard = [
+ [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")],
+ ]
- # Build connector buttons (2 per row)
- keyboard = []
- row = []
+ try:
+ await query.message.delete()
+ except:
+ pass
- for connector in cex_connectors:
- row.append(InlineKeyboardButton(
- connector,
- callback_data=f"bots:select_connector:{connector}"
- ))
- if len(row) == 2:
- keyboard.append(row)
- row = []
+ msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=r"*Edit Max Orders Per Batch*" + "\n\n"
+ f"Current: `{current}`" + "\n\n"
+ r"Enter new value \(integer\):",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ context.user_data["gs_wizard_message_id"] = msg.message_id
- if row:
- keyboard.append(row)
- keyboard.append([InlineKeyboardButton("Cancel", callback_data="bots:edit_config_back")])
- reply_markup = InlineKeyboardMarkup(keyboard)
+async def handle_gs_edit_min_amt(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Edit min order amount"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ chat_id = query.message.chat_id
- config = get_controller_config(context)
- current = config.get("connector_name", "") or "Not set"
+ context.user_data["bots_state"] = "gs_wizard_input"
+ context.user_data["gs_wizard_step"] = "edit_min_amt"
- await query.message.edit_text(
- r"*Select Connector*" + "\n\n"
- f"Current: `{escape_markdown_v2(current)}`\n\n"
- r"Choose an exchange from your configured connectors:",
- parse_mode="MarkdownV2",
- reply_markup=reply_markup
- )
+ current = config.get("min_order_amount_quote", 6)
- except Exception as e:
- logger.error(f"Error showing connector selector: {e}", exc_info=True)
- await query.answer(f"Error: {str(e)[:50]}", show_alert=True)
+ keyboard = [
+ [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")],
+ ]
+
+ try:
+ await query.message.delete()
+ except:
+ pass
+
+ msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=r"*Edit Min Order Amount*" + "\n\n"
+ f"Current: `{current}`" + "\n\n"
+ r"Enter new value \(e\.g\. 6\):",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ context.user_data["gs_wizard_message_id"] = msg.message_id
-async def handle_select_connector(update: Update, context: ContextTypes.DEFAULT_TYPE, connector_name: str) -> None:
- """Handle connector selection from keyboard"""
+async def handle_gs_edit_spread(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Edit min spread between orders"""
query = update.callback_query
-
config = get_controller_config(context)
- config["connector_name"] = connector_name
- set_controller_config(context, config)
+ chat_id = query.message.chat_id
- await query.answer(f"Connector set to {connector_name}")
+ context.user_data["bots_state"] = "gs_wizard_input"
+ context.user_data["gs_wizard_step"] = "edit_spread"
- # If we have both connector and trading pair, fetch market data
- if config.get("trading_pair"):
- await fetch_and_apply_market_data(update, context)
- else:
- await show_config_form(update, context)
+ current = config.get("min_spread_between_orders", 0.0001)
+ keyboard = [
+ [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")],
+ ]
-# ============================================
-# MARKET DATA & AUTO-PRICING
-# ============================================
+ try:
+ await query.message.delete()
+ except:
+ pass
-async def fetch_and_apply_market_data(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Fetch current price and candles, apply auto-pricing, show chart"""
+ msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=r"*Edit Min Spread Between Orders*" + "\n\n"
+ f"Current: `{current}`" + "\n\n"
+ r"Enter new value \(e\.g\. 0\.0002\):",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ context.user_data["gs_wizard_message_id"] = msg.message_id
+
+
+async def handle_gs_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Save the Grid Strike configuration"""
query = update.callback_query
config = get_controller_config(context)
- connector = config.get("connector_name")
- pair = config.get("trading_pair")
+ # Validate price ordering before saving
side = config.get("side", SIDE_LONG)
+ start_price = config.get("start_price", 0)
+ end_price = config.get("end_price", 0)
+ limit_price = config.get("limit_price", 0)
- if not connector or not pair:
- await show_config_form(update, context)
- return
-
- try:
- client = await get_bots_client()
+ validation_error = None
+ if side == SIDE_LONG:
+ if not (limit_price < start_price < end_price):
+ validation_error = (
+ "Invalid prices for LONG position\\.\n\n"
+ "Required: `limit < start < end`\n"
+ f"Current: `{limit_price:,.6g}` < `{start_price:,.6g}` < `{end_price:,.6g}`"
+ )
+ else: # SHORT
+ if not (start_price < end_price < limit_price):
+ validation_error = (
+ "Invalid prices for SHORT position\\.\n\n"
+ "Required: `start < end < limit`\n"
+ f"Current: `{start_price:,.6g}` < `{end_price:,.6g}` < `{limit_price:,.6g}`"
+ )
- # Show loading message
- await query.message.edit_text(
- f"Fetching market data for *{escape_markdown_v2(pair)}*\\.\\.\\.",
- parse_mode="MarkdownV2"
+ if validation_error:
+ await query.answer("Invalid price configuration", show_alert=True)
+ keyboard = [
+ [InlineKeyboardButton("Edit Prices", callback_data="bots:gs_back_to_prices")],
+ [InlineKeyboardButton("Cancel", callback_data="bots:main_menu")],
+ ]
+ try:
+ await query.message.delete()
+ except:
+ pass
+ msg = await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=f"⚠️ *Price Validation Error*\n\n{validation_error}",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
)
+ context.user_data["gs_wizard_message_id"] = msg.message_id
+ context.user_data["gs_wizard_chat_id"] = query.message.chat_id
+ return
- # Fetch current price
- current_price = await fetch_current_price(client, connector, pair)
+ config_id = config.get("id", "")
+ chat_id = query.message.chat_id
- if current_price:
- # Cache the current price
- context.user_data["grid_strike_current_price"] = current_price
+ # Delete the current message (could be photo or text)
+ try:
+ await query.message.delete()
+ except:
+ pass
- # Calculate auto prices
- start, end, limit = calculate_auto_prices(current_price, side)
- config["start_price"] = start
- config["end_price"] = end
- config["limit_price"] = limit
-
- # Generate auto ID with sequence number
- existing_configs = context.user_data.get("controller_configs_list", [])
- config["id"] = generate_config_id(connector, pair, existing_configs=existing_configs)
+ # Send saving status
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=f"Saving configuration `{escape_markdown_v2(config_id)}`\\.\\.\\.",
+ parse_mode="MarkdownV2"
+ )
- set_controller_config(context, config)
+ try:
+ client = await get_bots_client(chat_id)
+ result = await client.controllers.create_or_update_controller_config(config_id, config)
- # Fetch candles for chart
- candles = await fetch_candles(client, connector, pair, interval="5m", max_records=50)
+ # Clean up wizard state
+ _cleanup_wizard_state(context)
- if candles:
- # Generate and send chart
- chart_bytes = generate_candles_chart(
- candles,
- pair,
- start_price=start,
- end_price=end,
- limit_price=limit,
- current_price=current_price
- )
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_grid_strike")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
- # Send chart as photo
- await query.message.reply_photo(
- photo=chart_bytes,
- caption=(
- f"*{escape_markdown_v2(pair)}* Grid Zone\n\n"
- f"Current: `{current_price:,.4f}`\n"
- f"Start: `{start:,.4f}` \\(\\-2%\\)\n"
- f"End: `{end:,.4f}` \\(\\+2%\\)\n"
- f"Limit: `{limit:,.4f}`"
- ),
- parse_mode="MarkdownV2"
- )
- else:
- # No candles, just show price info
- await query.message.reply_text(
- f"*{escape_markdown_v2(pair)}* Market Data\n\n"
- f"Current Price: `{current_price:,.4f}`\n"
- f"Auto\\-calculated grid:\n"
- f" Start: `{start:,.4f}`\n"
- f" End: `{end:,.4f}`\n"
- f" Limit: `{limit:,.4f}`",
- parse_mode="MarkdownV2"
- )
- else:
- await query.message.reply_text(
- f"Could not fetch price for {pair}. Please set prices manually.",
- parse_mode="HTML"
- )
+ await status_msg.edit_text(
+ r"*Config Saved\!*" + "\n\n"
+ f"Controller `{escape_markdown_v2(config_id)}` saved successfully\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
except Exception as e:
- logger.error(f"Error fetching market data: {e}", exc_info=True)
- await query.message.reply_text(
- f"Error fetching market data: {str(e)[:100]}",
- parse_mode="HTML"
+ logger.error(f"Error saving config: {e}", exc_info=True)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:gs_save")],
+ [InlineKeyboardButton("Back", callback_data="bots:gs_review_back")],
+ ]
+ await status_msg.edit_text(
+ format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
)
- # Show the config form
- keyboard = [[InlineKeyboardButton("Continue Editing", callback_data="bots:edit_config_back")]]
- await query.message.reply_text(
- "Tap to continue editing configuration\\.",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
+async def handle_gs_review_back(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Go back to prices step (main configuration screen)"""
+ context.user_data["gs_wizard_step"] = "prices"
+ await _show_wizard_prices_step(update, context)
-async def handle_toggle_side(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Toggle the side between LONG and SHORT"""
- query = update.callback_query
- config = get_controller_config(context)
- current_side = config.get("side", SIDE_LONG)
- new_side = SIDE_SHORT if current_side == SIDE_LONG else SIDE_LONG
- config["side"] = new_side
+def _cleanup_wizard_state(context) -> None:
+ """Clean up wizard-related state"""
+ keys_to_remove = [
+ "gs_wizard_step", "gs_wizard_message_id", "gs_wizard_chat_id",
+ "gs_current_price", "gs_candles", "gs_chart_message_id",
+ "gs_market_data_ready", "gs_market_data_error",
+ "gs_chart_interval", "gs_candles_interval"
+ ]
+ for key in keys_to_remove:
+ context.user_data.pop(key, None)
+ clear_bots_state(context)
- # Recalculate prices if we have a current price cached
- current_price = context.user_data.get("grid_strike_current_price")
- if current_price:
- start, end, limit = calculate_auto_prices(current_price, new_side)
- config["start_price"] = start
- config["end_price"] = end
- config["limit_price"] = limit
- # Regenerate ID with sequence number
- if config.get("connector_name") and config.get("trading_pair"):
- existing_configs = context.user_data.get("controller_configs_list", [])
- config["id"] = generate_config_id(
- config["connector_name"],
- config["trading_pair"],
- existing_configs=existing_configs
- )
+async def _background_fetch_market_data(context, config: dict, chat_id: int = None) -> None:
+ """Background task to fetch market data while user continues with wizard"""
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
- set_controller_config(context, config)
+ if not connector or not pair:
+ return
- # Refresh the form
- await show_config_form(update, context)
+ try:
+ client = await get_bots_client(chat_id)
+ # Fetch current price
+ current_price = await fetch_current_price(client, connector, pair)
-async def handle_cycle_order_type(update: Update, context: ContextTypes.DEFAULT_TYPE, order_type_key: str) -> None:
- """Cycle the order type between Market, Limit, and Limit Maker
+ if current_price:
+ context.user_data["gs_current_price"] = current_price
- Args:
- update: Telegram update
- context: Telegram context
- order_type_key: 'open' for open_order_type, 'tp' for take_profit_order_type
- """
- query = update.callback_query
- config = get_controller_config(context)
+ # Fetch candles (1m, 420 records) - consistent with default interval
+ candles = await fetch_candles(client, connector, pair, interval="1m", max_records=420)
+ context.user_data["gs_candles"] = candles
+ context.user_data["gs_candles_interval"] = "1m"
+ context.user_data["gs_market_data_ready"] = True
- # Determine which field to update
- field_name = "open_order_type" if order_type_key == "open" else "take_profit_order_type"
+ logger.info(f"Background fetch complete for {pair}: price={current_price}")
+ else:
+ context.user_data["gs_market_data_error"] = f"Could not fetch price for {pair}"
- # Get current value
- if "triple_barrier_config" not in config:
- config["triple_barrier_config"] = GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy()
+ except Exception as e:
+ logger.error(f"Background fetch error for {pair}: {e}")
+ context.user_data["gs_market_data_error"] = str(e)
- current_type = config["triple_barrier_config"].get(field_name, ORDER_TYPE_LIMIT_MAKER)
- # Cycle: Limit Maker -> Market -> Limit -> Limit Maker
- order_cycle = [ORDER_TYPE_LIMIT_MAKER, ORDER_TYPE_MARKET, ORDER_TYPE_LIMIT]
+async def process_gs_wizard_input(update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str) -> None:
+ """Process text input during wizard flow"""
+ step = context.user_data.get("gs_wizard_step")
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ if not step:
+ return
+
try:
- current_index = order_cycle.index(current_type)
- next_index = (current_index + 1) % len(order_cycle)
- except ValueError:
- next_index = 0
+ # Delete user's message
+ try:
+ await update.message.delete()
+ except:
+ pass
- new_type = order_cycle[next_index]
- config["triple_barrier_config"][field_name] = new_type
+ if step == "trading_pair":
+ # Validate and set trading pair
+ pair = user_input.upper().strip()
+ if "-" not in pair:
+ pair = pair.replace("/", "-").replace("_", "-")
- set_controller_config(context, config)
+ config["trading_pair"] = pair
+ set_controller_config(context, config)
- # Refresh the form
- await show_config_form(update, context)
+ # Start background fetch of market data
+ asyncio.create_task(_background_fetch_market_data(context, config, chat_id))
+ # Move to side step
+ context.user_data["gs_wizard_step"] = "side"
-async def process_field_input(update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str) -> None:
- """Process user input for a field
+ # Update the wizard message
+ await _update_wizard_message_for_side(update, context)
- Args:
- update: Telegram update
- context: Telegram context
- user_input: The text the user entered
- """
- field_name = context.user_data.get("editing_controller_field")
+ elif step == "prices":
+ # Handle multiple input formats:
+ # 1. field=value - set any field (e.g., start_price=130, order_frequency=5)
+ # 2. start,end,limit - price values (legacy)
+ # 3. tp:0.1 - take profit percentage (legacy)
+ # 4. spread:0.05 - min spread percentage (legacy)
+ # 5. min:10 - min order amount (legacy)
+ input_stripped = user_input.strip()
+ input_lower = input_stripped.lower()
+
+ # Check for field=value format first
+ if "=" in input_stripped:
+ # Parse field=value format
+ changes_made = False
+ for line in input_stripped.split("\n"):
+ line = line.strip()
+ if not line or "=" not in line:
+ continue
+
+ field, value = line.split("=", 1)
+ field = field.strip().lower()
+ value = value.strip()
+
+ # Map field names and set values
+ if field in ("start_price", "start"):
+ config["start_price"] = float(value)
+ changes_made = True
+ elif field in ("end_price", "end"):
+ config["end_price"] = float(value)
+ changes_made = True
+ elif field in ("limit_price", "limit"):
+ config["limit_price"] = float(value)
+ changes_made = True
+ elif field in ("take_profit", "tp"):
+ # Support both decimal (0.001) and percentage (0.1%)
+ val = float(value.replace("%", ""))
+ if val > 1: # Likely percentage like 0.1
+ val = val / 100
+ config.setdefault("triple_barrier_config", GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy())
+ config["triple_barrier_config"]["take_profit"] = val
+ changes_made = True
+ elif field in ("min_spread_between_orders", "min_spread", "spread"):
+ val = float(value.replace("%", ""))
+ if val > 1: # Likely percentage
+ val = val / 100
+ config["min_spread_between_orders"] = val
+ changes_made = True
+ elif field in ("min_order_amount_quote", "min_order_amount", "min_order", "min"):
+ config["min_order_amount_quote"] = float(value.replace("$", ""))
+ changes_made = True
+ elif field in ("total_amount_quote", "total_amount", "amount"):
+ config["total_amount_quote"] = float(value)
+ changes_made = True
+ elif field == "leverage":
+ config["leverage"] = int(float(value))
+ changes_made = True
+ elif field == "side":
+ config["side"] = int(float(value))
+ changes_made = True
+ elif field in ("max_open_orders", "max_orders"):
+ config["max_open_orders"] = int(float(value))
+ changes_made = True
+ elif field == "order_frequency":
+ config["order_frequency"] = int(float(value))
+ changes_made = True
+ elif field == "max_orders_per_batch":
+ config["max_orders_per_batch"] = int(float(value))
+ changes_made = True
+ elif field == "activation_bounds":
+ val = float(value.replace("%", ""))
+ if val > 1: # Likely percentage
+ val = val / 100
+ config["activation_bounds"] = val
+ changes_made = True
+
+ if changes_made:
+ set_controller_config(context, config)
+ await _update_wizard_message_for_prices_after_edit(update, context)
+ else:
+ raise ValueError(f"Unknown field: {field}")
+
+ elif input_lower.startswith("tp:"):
+ # Take profit in percentage (e.g., tp:0.1 = 0.1% = 0.001)
+ tp_pct = float(input_lower.replace("tp:", "").replace("%", "").strip())
+ tp_decimal = tp_pct / 100
+ if "triple_barrier_config" not in config:
+ config["triple_barrier_config"] = GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy()
+ config["triple_barrier_config"]["take_profit"] = tp_decimal
+ set_controller_config(context, config)
+ await _update_wizard_message_for_prices_after_edit(update, context)
- if not field_name:
- await update.message.reply_text("No field selected. Please try again.")
- return
+ elif input_lower.startswith("spread:"):
+ # Min spread in percentage (e.g., spread:0.05 = 0.05% = 0.0005)
+ spread_pct = float(input_lower.replace("spread:", "").replace("%", "").strip())
+ spread_decimal = spread_pct / 100
+ config["min_spread_between_orders"] = spread_decimal
+ set_controller_config(context, config)
+ await _update_wizard_message_for_prices_after_edit(update, context)
- field_info = GRID_STRIKE_FIELDS.get(field_name, {})
- field_type = field_info.get("type", "str")
- label = field_info.get("label", field_name)
+ elif input_lower.startswith("min:"):
+ # Min order amount in quote (e.g., min:10 = $10)
+ min_amt = float(input_lower.replace("min:", "").replace("$", "").strip())
+ config["min_order_amount_quote"] = min_amt
+ set_controller_config(context, config)
+ await _update_wizard_message_for_prices_after_edit(update, context)
- config = get_controller_config(context)
+ else:
+ # Parse comma-separated prices: start,end,limit
+ parts = user_input.replace(" ", "").split(",")
+ if len(parts) == 3:
+ config["start_price"] = float(parts[0])
+ config["end_price"] = float(parts[1])
+ config["limit_price"] = float(parts[2])
+ set_controller_config(context, config)
+ # Stay in prices step to show updated values
+ await _update_wizard_message_for_prices_after_edit(update, context)
+ elif len(parts) == 1:
+ # Single price - ask which one to update
+ raise ValueError("Use format: field=value (e.g., start_price=130)")
+ else:
+ raise ValueError("Invalid format")
- try:
- # Parse the value based on type
- if field_type == "int":
- value = int(user_input)
- elif field_type == "float":
- value = float(user_input)
- else:
- value = user_input.strip()
+ elif step == "take_profit":
+ # Parse take profit - interpret as percentage (0.4 = 0.4% = 0.004)
+ tp_input = user_input.replace("%", "").strip()
+ tp_pct = float(tp_input)
+ tp_decimal = tp_pct / 100 # Convert 0.4 -> 0.004
- # Set the value
- if field_name == "take_profit":
+ config = get_controller_config(context)
if "triple_barrier_config" not in config:
config["triple_barrier_config"] = GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy()
- config["triple_barrier_config"]["take_profit"] = value
- else:
- config[field_name] = value
+ config["triple_barrier_config"]["take_profit"] = tp_decimal
+ set_controller_config(context, config)
- set_controller_config(context, config)
+ # Move to review step
+ context.user_data["gs_wizard_step"] = "review"
+ await _update_wizard_message_for_review(update, context)
- # Clear field editing state
- context.user_data.pop("editing_controller_field", None)
- context.user_data["bots_state"] = "editing_config"
+ elif step == "total_amount_quote":
+ amount = float(user_input)
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
- # Show success
- await update.message.reply_text(
+ # Move to prices step
+ context.user_data["gs_wizard_step"] = "prices"
+ await _update_wizard_message_for_prices(update, context)
+
+ elif step == "edit_id":
+ new_id = user_input.strip()
+ config["id"] = new_id
+ set_controller_config(context, config)
+
+ # Save immediately
+ context.user_data["gs_wizard_step"] = "review"
+ await _trigger_gs_save(update, context)
+
+ elif step in ["start_price", "end_price", "limit_price"]:
+ price = float(user_input)
+ price_field = step.replace("_price", "_price")
+ config[step] = price
+ set_controller_config(context, config)
+
+ # Go back to prices step
+ context.user_data["gs_wizard_step"] = "prices"
+ await _update_wizard_message_for_prices_after_edit(update, context)
+
+ elif step == "edit_tp":
+ tp_input = user_input.replace("%", "").strip()
+ tp_pct = float(tp_input)
+ tp_decimal = tp_pct / 100 # Convert 0.03 -> 0.0003
+ if "triple_barrier_config" not in config:
+ config["triple_barrier_config"] = GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy()
+ config["triple_barrier_config"]["take_profit"] = tp_decimal
+ set_controller_config(context, config)
+ context.user_data["gs_wizard_step"] = "review"
+ await _update_wizard_message_for_review(update, context)
+
+ elif step == "edit_act":
+ act_input = user_input.replace("%", "").strip()
+ act_pct = float(act_input)
+ act_decimal = act_pct / 100 # Convert 1 -> 0.01
+ config["activation_bounds"] = act_decimal
+ set_controller_config(context, config)
+ context.user_data["gs_wizard_step"] = "review"
+ await _update_wizard_message_for_review(update, context)
+
+ elif step == "edit_max_orders":
+ config["max_open_orders"] = int(user_input)
+ set_controller_config(context, config)
+ context.user_data["gs_wizard_step"] = "review"
+ await _update_wizard_message_for_review(update, context)
+
+ elif step == "edit_batch":
+ config["max_orders_per_batch"] = int(user_input)
+ set_controller_config(context, config)
+ context.user_data["gs_wizard_step"] = "review"
+ await _update_wizard_message_for_review(update, context)
+
+ elif step == "edit_min_amt":
+ config["min_order_amount_quote"] = float(user_input)
+ set_controller_config(context, config)
+ context.user_data["gs_wizard_step"] = "review"
+ await _update_wizard_message_for_review(update, context)
+
+ elif step == "edit_spread":
+ config["min_spread_between_orders"] = float(user_input)
+ set_controller_config(context, config)
+ context.user_data["gs_wizard_step"] = "review"
+ await _update_wizard_message_for_review(update, context)
+
+ elif step == "review":
+ # Parse field: value or field=value pairs (YAML-style)
+ field_map = {
+ # Real YAML field names
+ "id": "id",
+ "connector_name": "connector_name",
+ "trading_pair": "trading_pair",
+ "side": "side",
+ "leverage": "leverage",
+ "total_amount_quote": "total_amount_quote",
+ "start_price": "start_price",
+ "end_price": "end_price",
+ "limit_price": "limit_price",
+ "take_profit": "triple_barrier_config.take_profit",
+ "keep_position": "keep_position",
+ "activation_bounds": "activation_bounds",
+ "max_open_orders": "max_open_orders",
+ "max_orders_per_batch": "max_orders_per_batch",
+ "min_order_amount_quote": "min_order_amount_quote",
+ "min_spread_between_orders": "min_spread_between_orders",
+ }
+
+ updated_fields = []
+ lines = user_input.strip().split("\n")
+ for line in lines:
+ line = line.strip()
+ # Support both YAML style (field: value) and equals style (field=value)
+ if ":" in line:
+ key, value = line.split(":", 1)
+ elif "=" in line:
+ key, value = line.split("=", 1)
+ else:
+ continue
+ key = key.strip().lower()
+ value = value.strip()
+
+ if key not in field_map:
+ continue
+
+ field = field_map[key]
+
+ # Handle special cases
+ if key == "side":
+ # Accept both numeric (1, 2) and text (LONG, SHORT)
+ if value in ("1", "LONG", "long"):
+ config["side"] = SIDE_LONG
+ else:
+ config["side"] = SIDE_SHORT
+ elif key == "keep_position":
+ config["keep_position"] = value.lower() in ("true", "yes", "y", "1")
+ elif key == "take_profit":
+ if "triple_barrier_config" not in config:
+ config["triple_barrier_config"] = GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy()
+ config["triple_barrier_config"]["take_profit"] = float(value)
+ elif field in ["leverage", "max_open_orders", "max_orders_per_batch"]:
+ config[field] = int(value)
+ elif field in ["total_amount_quote", "start_price", "end_price", "limit_price",
+ "activation_bounds", "min_order_amount_quote", "min_spread_between_orders"]:
+ config[field] = float(value)
+ else:
+ config[field] = value
+
+ updated_fields.append(key)
+
+ if updated_fields:
+ set_controller_config(context, config)
+ await _update_wizard_message_for_review(update, context)
+ else:
+ raise ValueError("No valid fields found")
+
+ except ValueError:
+ # Send error and let user try again
+ error_msg = await update.message.reply_text(
+ f"Invalid input. Please enter a valid value."
+ )
+ # Auto-delete error after 3 seconds
+ await asyncio.sleep(3)
+ try:
+ await error_msg.delete()
+ except:
+ pass
+
+
+async def _update_wizard_message_for_side(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Update wizard message to show side step after pair input"""
+ config = get_controller_config(context)
+ message_id = context.user_data.get("gs_wizard_message_id")
+ chat_id = context.user_data.get("gs_wizard_chat_id")
+
+ if not message_id or not chat_id:
+ return
+
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+
+ keyboard = [
+ [
+ InlineKeyboardButton("📈 LONG", callback_data="bots:gs_side:long"),
+ InlineKeyboardButton("📉 SHORT", callback_data="bots:gs_side:short"),
+ ],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+
+ # Determine total steps based on connector type
+ is_perp = connector.endswith("_perpetual")
+ total_steps = 6 if is_perp else 5
+
+ try:
+ await context.bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text=(
+ rf"*📈 Grid Strike \- Step 3/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"🎯 *Select Side*"
+ ),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ except Exception as e:
+ logger.error(f"Error updating wizard message: {e}")
+
+
+async def _update_wizard_message_for_prices(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Trigger prices step after amount input"""
+ message_id = context.user_data.get("gs_wizard_message_id")
+ chat_id = context.user_data.get("gs_wizard_chat_id")
+
+ if not message_id or not chat_id:
+ return
+
+ # Create a fake query object to reuse _show_wizard_prices_step
+ class FakeChat:
+ def __init__(self, chat_id):
+ self.id = chat_id
+
+ class FakeQuery:
+ def __init__(self, bot, chat_id, message_id):
+ self.message = FakeMessage(bot, chat_id, message_id)
+
+ class FakeMessage:
+ def __init__(self, bot, chat_id, message_id):
+ self.chat_id = chat_id
+ self.message_id = message_id
+ self._bot = bot
+
+ async def edit_text(self, text, **kwargs):
+ await self._bot.edit_message_text(
+ chat_id=self.chat_id,
+ message_id=self.message_id,
+ text=text,
+ **kwargs
+ )
+
+ async def delete(self):
+ await self._bot.delete_message(chat_id=self.chat_id, message_id=self.message_id)
+
+ fake_update = type('FakeUpdate', (), {
+ 'callback_query': FakeQuery(context.bot, chat_id, message_id),
+ 'effective_chat': FakeChat(chat_id)
+ })()
+ await _show_wizard_prices_step(fake_update, context)
+
+
+async def _update_wizard_message_for_prices_after_edit(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Update prices display after editing prices - regenerate chart with new prices and grid analysis"""
+ config = get_controller_config(context)
+ message_id = context.user_data.get("gs_wizard_message_id")
+ chat_id = context.user_data.get("gs_wizard_chat_id")
+
+ if not message_id or not chat_id:
+ return
+
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ side = config.get("side", SIDE_LONG)
+ side_str = "📈 LONG" if side == SIDE_LONG else "📉 SHORT"
+ start = config.get("start_price", 0)
+ end = config.get("end_price", 0)
+ limit = config.get("limit_price", 0)
+ current_price = context.user_data.get("gs_current_price", 0)
+ candles = context.user_data.get("gs_candles")
+ interval = context.user_data.get("gs_chart_interval", "1m")
+ total_amount = config.get("total_amount_quote", 1000)
+ min_spread = config.get("min_spread_between_orders", 0.0001)
+ take_profit = config.get("triple_barrier_config", {}).get("take_profit", 0.0001)
+ min_order_amount = config.get("min_order_amount_quote", 6)
+ natr = context.user_data.get("gs_natr")
+ trading_rules = context.user_data.get("gs_trading_rules", {})
+
+ # Regenerate theoretical grid with updated parameters
+ grid = generate_theoretical_grid(
+ start_price=start,
+ end_price=end,
+ min_spread=min_spread,
+ total_amount=total_amount,
+ min_order_amount=min_order_amount,
+ current_price=current_price,
+ side=side,
+ trading_rules=trading_rules,
+ )
+ context.user_data["gs_theoretical_grid"] = grid
+
+ # Build interval buttons with current one highlighted
+ interval_options = ["1m", "5m", "15m", "1h", "4h"]
+ interval_row = []
+ for opt in interval_options:
+ label = f"✓ {opt}" if opt == interval else opt
+ interval_row.append(InlineKeyboardButton(label, callback_data=f"bots:gs_interval:{opt}"))
+
+ keyboard = [
+ interval_row,
+ [
+ InlineKeyboardButton("💾 Save Config", callback_data="bots:gs_save"),
+ ],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+
+ # Get config values
+ max_open_orders = config.get("max_open_orders", 3)
+ order_frequency = config.get("order_frequency", 3)
+ leverage = config.get("leverage", 1)
+ side_value = config.get("side", SIDE_LONG)
+ side_str = "LONG" if side_value == SIDE_LONG else "SHORT"
+
+ # Grid analysis info
+ grid_valid = "✓" if grid.get("valid") else "⚠️"
+ natr_pct = f"{natr*100:.2f}%" if natr else "N/A"
+ range_pct = f"{grid.get('grid_range_pct', 0):.2f}%"
+
+ # Build config text with individually copyable key=value params
+ config_text = (
+ f"*{escape_markdown_v2(pair)}* {side_str}\n"
+ f"Price: `{current_price:,.6g}` \\| Range: `{range_pct}` \\| NATR: `{natr_pct}`\n\n"
+ f"`total_amount_quote={total_amount:.0f}`\n"
+ f"`start_price={start:.6g}`\n"
+ f"`end_price={end:.6g}`\n"
+ f"`limit_price={limit:.6g}`\n"
+ f"`leverage={leverage}`\n"
+ f"`take_profit={take_profit}`\n"
+ f"`min_spread_between_orders={min_spread}`\n"
+ f"`min_order_amount_quote={min_order_amount:.0f}`\n"
+ f"`max_open_orders={max_open_orders}`\n\n"
+ f"{grid_valid} Grid: `{grid['num_levels']}` levels "
+ f"\\(↓{grid.get('levels_below_current', 0)} ↑{grid.get('levels_above_current', 0)}\\) "
+ f"@ `${grid['amount_per_level']:.2f}`/lvl"
+ )
+
+ # Add warnings if any
+ if grid.get("warnings"):
+ warnings_text = "\n".join(f"⚠️ {escape_markdown_v2(w)}" for w in grid["warnings"])
+ config_text += f"\n{warnings_text}"
+
+ config_text += "\n\n_Edit: `field=value`_"
+
+ try:
+ # Delete old message (which is a photo)
+ try:
+ await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
+ except Exception:
+ pass
+
+ # Get candles list
+ candles_list = candles.get("data", []) if isinstance(candles, dict) else candles
+
+ # Generate new chart with updated prices
+ if candles_list:
+ chart_bytes = generate_candles_chart(
+ candles_list, pair,
+ start_price=start,
+ end_price=end,
+ limit_price=limit,
+ current_price=current_price,
+ side=side
+ )
+
+ # Send new photo with updated caption
+ msg = await context.bot.send_photo(
+ chat_id=chat_id,
+ photo=chart_bytes,
+ caption=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
+ # Update stored message ID
+ context.user_data["gs_wizard_message_id"] = msg.message_id
+ else:
+ # No chart - send text message
+ msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ context.user_data["gs_wizard_message_id"] = msg.message_id
+
+ except Exception as e:
+ logger.error(f"Error updating prices message: {e}", exc_info=True)
+
+
+async def handle_gs_edit_price(update: Update, context: ContextTypes.DEFAULT_TYPE, price_type: str) -> None:
+ """Handle price editing request"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ price_map = {
+ "start": ("start_price", "Start Price"),
+ "end": ("end_price", "End Price"),
+ "limit": ("limit_price", "Limit Price"),
+ }
+
+ field, label = price_map.get(price_type, ("start_price", "Start Price"))
+ current = config.get(field, 0)
+
+ context.user_data["bots_state"] = "gs_wizard_input"
+ context.user_data["gs_wizard_step"] = field
+
+ keyboard = [[InlineKeyboardButton("Cancel", callback_data="bots:gs_back_to_prices")]]
+
+ await query.message.edit_text(
+ f"*Edit {escape_markdown_v2(label)}*" + "\n\n"
+ f"Current: `{current:,.6g}`" + "\n\n"
+ r"Enter new price:",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
+
+async def _trigger_gs_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Trigger save after ID edit"""
+ message_id = context.user_data.get("gs_wizard_message_id")
+ chat_id = context.user_data.get("gs_wizard_chat_id")
+
+ if not message_id or not chat_id:
+ return
+
+ class FakeQuery:
+ def __init__(self, bot, chat_id, message_id):
+ self.message = FakeMessage(bot, chat_id, message_id)
+
+ class FakeMessage:
+ def __init__(self, bot, chat_id, message_id):
+ self.chat_id = chat_id
+ self.message_id = message_id
+ self._bot = bot
+
+ async def edit_text(self, text, **kwargs):
+ await self._bot.edit_message_text(
+ chat_id=self.chat_id,
+ message_id=self.message_id,
+ text=text,
+ **kwargs
+ )
+
+ fake_update = type('FakeUpdate', (), {'callback_query': FakeQuery(context.bot, chat_id, message_id)})()
+ await handle_gs_save(fake_update, context)
+
+
+# ============================================
+# LEGACY FORM (for edit mode)
+# ============================================
+
+async def show_config_form(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show the configuration form with current values (legacy/edit mode)"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ if not config:
+ config = init_new_controller_config(context, "grid_strike")
+
+ # Build the form display
+ lines = [r"*Grid Strike Configuration*", ""]
+
+ # Show current values
+ for field_name in GRID_STRIKE_FIELD_ORDER:
+ field_info = GRID_STRIKE_FIELDS[field_name]
+ label = field_info["label"]
+
+ # Get value, handling nested triple_barrier_config
+ if field_name == "take_profit":
+ value = config.get("triple_barrier_config", {}).get("take_profit", 0.0001)
+ elif field_name == "open_order_type":
+ value = config.get("triple_barrier_config", {}).get("open_order_type", ORDER_TYPE_LIMIT_MAKER)
+ elif field_name == "take_profit_order_type":
+ value = config.get("triple_barrier_config", {}).get("take_profit_order_type", ORDER_TYPE_LIMIT_MAKER)
+ else:
+ value = config.get(field_name, "")
+
+ formatted_value = format_config_field_value(field_name, value)
+ required = "\\*" if field_info.get("required") else ""
+
+ lines.append(f"*{escape_markdown_v2(label)}*{required}: `{escape_markdown_v2(formatted_value)}`")
+
+ lines.append("")
+ lines.append(r"_Tap a button to edit a field\. \* \= required_")
+
+ # Build keyboard with field buttons
+ keyboard = []
+
+ # Row 1: ID and Connector
+ keyboard.append([
+ InlineKeyboardButton("ID", callback_data="bots:set_field:id"),
+ InlineKeyboardButton("Connector", callback_data="bots:set_field:connector_name"),
+ InlineKeyboardButton("Pair", callback_data="bots:set_field:trading_pair"),
+ ])
+
+ # Row 2: Side and Leverage
+ keyboard.append([
+ InlineKeyboardButton("Side", callback_data="bots:toggle_side"),
+ InlineKeyboardButton("Leverage", callback_data="bots:set_field:leverage"),
+ InlineKeyboardButton("Amount", callback_data="bots:set_field:total_amount_quote"),
+ ])
+
+ # Row 3: Prices
+ keyboard.append([
+ InlineKeyboardButton("Start Price", callback_data="bots:set_field:start_price"),
+ InlineKeyboardButton("End Price", callback_data="bots:set_field:end_price"),
+ InlineKeyboardButton("Limit Price", callback_data="bots:set_field:limit_price"),
+ ])
+
+ # Row 4: Advanced
+ keyboard.append([
+ InlineKeyboardButton("Max Orders", callback_data="bots:set_field:max_open_orders"),
+ InlineKeyboardButton("Min Spread", callback_data="bots:set_field:min_spread_between_orders"),
+ InlineKeyboardButton("Take Profit", callback_data="bots:set_field:take_profit"),
+ ])
+
+ # Row 5: Order Types
+ keyboard.append([
+ InlineKeyboardButton("Open Order Type", callback_data="bots:cycle_order_type:open"),
+ InlineKeyboardButton("TP Order Type", callback_data="bots:cycle_order_type:tp"),
+ ])
+
+ # Row 6: Actions
+ keyboard.append([
+ InlineKeyboardButton("Save Config", callback_data="bots:save_config"),
+ InlineKeyboardButton("Cancel", callback_data="bots:controller_configs"),
+ ])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await query.message.edit_text(
+ "\n".join(lines),
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+
+
+# ============================================
+# FIELD EDITING
+# ============================================
+
+async def handle_set_field(update: Update, context: ContextTypes.DEFAULT_TYPE, field_name: str) -> None:
+ """Prompt user to enter a value for a field
+
+ Args:
+ update: Telegram update
+ context: Telegram context
+ field_name: Name of the field to edit
+ """
+ query = update.callback_query
+
+ # Special handling for connector_name - show button selector
+ if field_name == "connector_name":
+ await show_connector_selector(update, context)
+ return
+
+ field_info = GRID_STRIKE_FIELDS.get(field_name, {})
+ label = field_info.get("label", field_name)
+ hint = field_info.get("hint", "")
+ field_type = field_info.get("type", "str")
+
+ # Set state for text input
+ context.user_data["bots_state"] = f"set_field:{field_name}"
+ context.user_data["editing_controller_field"] = field_name
+
+ # Get current value
+ config = get_controller_config(context)
+ if field_name == "take_profit":
+ current = config.get("triple_barrier_config", {}).get("take_profit", 0.0001)
+ else:
+ current = config.get(field_name, "")
+
+ current_str = format_config_field_value(field_name, current)
+
+ message = (
+ f"*Set {escape_markdown_v2(label)}*\n\n"
+ f"Current: `{escape_markdown_v2(current_str)}`\n\n"
+ )
+
+ if hint:
+ message += f"_Hint: {escape_markdown_v2(hint)}_\n\n"
+
+ message += r"Type the new value or tap Cancel\."
+
+ keyboard = [[InlineKeyboardButton("Cancel", callback_data="bots:edit_config_back")]]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await query.message.edit_text(
+ message,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+
+
+# ============================================
+# CONNECTOR SELECTOR
+# ============================================
+
+async def show_connector_selector(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show connector selection keyboard with available CEX connectors"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ try:
+ client = await get_bots_client(chat_id)
+
+ # Get available CEX connectors (with cache)
+ cex_connectors = await get_available_cex_connectors(context.user_data, client)
+
+ if not cex_connectors:
+ await query.answer("No CEX connectors configured", show_alert=True)
+ return
+
+ # Build connector buttons (2 per row)
+ keyboard = []
+ row = []
+
+ for connector in cex_connectors:
+ row.append(InlineKeyboardButton(
+ connector,
+ callback_data=f"bots:select_connector:{connector}"
+ ))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+
+ if row:
+ keyboard.append(row)
+
+ keyboard.append([InlineKeyboardButton("Cancel", callback_data="bots:edit_config_back")])
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ config = get_controller_config(context)
+ current = config.get("connector_name", "") or "Not set"
+
+ await query.message.edit_text(
+ r"*Select Connector*" + "\n\n"
+ f"Current: `{escape_markdown_v2(current)}`\n\n"
+ r"Choose an exchange from your configured connectors:",
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+
+ except Exception as e:
+ logger.error(f"Error showing connector selector: {e}", exc_info=True)
+ await query.answer(f"Error: {str(e)[:50]}", show_alert=True)
+
+
+async def handle_select_connector(update: Update, context: ContextTypes.DEFAULT_TYPE, connector_name: str) -> None:
+ """Handle connector selection from keyboard"""
+ query = update.callback_query
+
+ config = get_controller_config(context)
+ config["connector_name"] = connector_name
+ set_controller_config(context, config)
+
+ await query.answer(f"Connector set to {connector_name}")
+
+ # If we have both connector and trading pair, fetch market data
+ if config.get("trading_pair"):
+ await fetch_and_apply_market_data(update, context)
+ else:
+ await show_config_form(update, context)
+
+
+# ============================================
+# MARKET DATA & AUTO-PRICING
+# ============================================
+
+async def fetch_and_apply_market_data(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Fetch current price and candles, apply auto-pricing, show chart"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ connector = config.get("connector_name")
+ pair = config.get("trading_pair")
+ side = config.get("side", SIDE_LONG)
+
+ if not connector or not pair:
+ await show_config_form(update, context)
+ return
+
+ try:
+ client = await get_bots_client(chat_id)
+
+ # Show loading message
+ await query.message.edit_text(
+ f"Fetching market data for *{escape_markdown_v2(pair)}*\\.\\.\\.",
+ parse_mode="MarkdownV2"
+ )
+
+ # Fetch current price
+ current_price = await fetch_current_price(client, connector, pair)
+
+ if current_price:
+ # Cache the current price
+ context.user_data["grid_strike_current_price"] = current_price
+
+ # Calculate auto prices
+ start, end, limit = calculate_auto_prices(current_price, side)
+ config["start_price"] = start
+ config["end_price"] = end
+ config["limit_price"] = limit
+
+ # Generate auto ID with sequence number
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ config["id"] = generate_config_id(connector, pair, existing_configs=existing_configs)
+
+ set_controller_config(context, config)
+
+ # Fetch candles for chart
+ candles = await fetch_candles(client, connector, pair, interval="5m", max_records=420)
+
+ if candles:
+ # Generate and send chart
+ chart_bytes = generate_candles_chart(
+ candles,
+ pair,
+ start_price=start,
+ end_price=end,
+ limit_price=limit,
+ current_price=current_price
+ )
+
+ # Send chart as photo
+ await query.message.reply_photo(
+ photo=chart_bytes,
+ caption=(
+ f"*{escape_markdown_v2(pair)}* Grid Zone\n\n"
+ f"Current: `{current_price:,.4f}`\n"
+ f"Start: `{start:,.4f}` \\(\\-2%\\)\n"
+ f"End: `{end:,.4f}` \\(\\+2%\\)\n"
+ f"Limit: `{limit:,.4f}`"
+ ),
+ parse_mode="MarkdownV2"
+ )
+ else:
+ # No candles, just show price info
+ await query.message.reply_text(
+ f"*{escape_markdown_v2(pair)}* Market Data\n\n"
+ f"Current Price: `{current_price:,.4f}`\n"
+ f"Auto\\-calculated grid:\n"
+ f" Start: `{start:,.4f}`\n"
+ f" End: `{end:,.4f}`\n"
+ f" Limit: `{limit:,.4f}`",
+ parse_mode="MarkdownV2"
+ )
+ else:
+ await query.message.reply_text(
+ f"Could not fetch price for {pair}. Please set prices manually.",
+ parse_mode="HTML"
+ )
+
+ except Exception as e:
+ logger.error(f"Error fetching market data: {e}", exc_info=True)
+ await query.message.reply_text(
+ f"Error fetching market data: {str(e)[:100]}",
+ parse_mode="HTML"
+ )
+
+ # Show the config form
+ keyboard = [[InlineKeyboardButton("Continue Editing", callback_data="bots:edit_config_back")]]
+ await query.message.reply_text(
+ "Tap to continue editing configuration\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
+
+async def handle_toggle_side(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Toggle the side between LONG and SHORT"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ current_side = config.get("side", SIDE_LONG)
+ new_side = SIDE_SHORT if current_side == SIDE_LONG else SIDE_LONG
+ config["side"] = new_side
+
+ # Recalculate prices if we have a current price cached
+ current_price = context.user_data.get("grid_strike_current_price")
+ if current_price:
+ start, end, limit = calculate_auto_prices(current_price, new_side)
+ config["start_price"] = start
+ config["end_price"] = end
+ config["limit_price"] = limit
+
+ # Regenerate ID with sequence number
+ if config.get("connector_name") and config.get("trading_pair"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ config["id"] = generate_config_id(
+ config["connector_name"],
+ config["trading_pair"],
+ existing_configs=existing_configs
+ )
+
+ set_controller_config(context, config)
+
+ # Refresh the form
+ await show_config_form(update, context)
+
+
+async def handle_cycle_order_type(update: Update, context: ContextTypes.DEFAULT_TYPE, order_type_key: str) -> None:
+ """Cycle the order type between Market, Limit, and Limit Maker
+
+ Args:
+ update: Telegram update
+ context: Telegram context
+ order_type_key: 'open' for open_order_type, 'tp' for take_profit_order_type
+ """
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ # Determine which field to update
+ field_name = "open_order_type" if order_type_key == "open" else "take_profit_order_type"
+
+ # Get current value
+ if "triple_barrier_config" not in config:
+ config["triple_barrier_config"] = GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy()
+
+ current_type = config["triple_barrier_config"].get(field_name, ORDER_TYPE_LIMIT_MAKER)
+
+ # Cycle: Limit Maker -> Market -> Limit -> Limit Maker
+ order_cycle = [ORDER_TYPE_LIMIT_MAKER, ORDER_TYPE_MARKET, ORDER_TYPE_LIMIT]
+ try:
+ current_index = order_cycle.index(current_type)
+ next_index = (current_index + 1) % len(order_cycle)
+ except ValueError:
+ next_index = 0
+
+ new_type = order_cycle[next_index]
+ config["triple_barrier_config"][field_name] = new_type
+
+ set_controller_config(context, config)
+
+ # Refresh the form
+ await show_config_form(update, context)
+
+
+async def process_field_input(update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str) -> None:
+ """Process user input for a field
+
+ Args:
+ update: Telegram update
+ context: Telegram context
+ user_input: The text the user entered
+ """
+ chat_id = update.effective_chat.id
+ field_name = context.user_data.get("editing_controller_field")
+
+ if not field_name:
+ await update.message.reply_text("No field selected. Please try again.")
+ return
+
+ field_info = GRID_STRIKE_FIELDS.get(field_name, {})
+ field_type = field_info.get("type", "str")
+ label = field_info.get("label", field_name)
+
+ config = get_controller_config(context)
+
+ try:
+ # Parse the value based on type
+ if field_type == "int":
+ value = int(user_input)
+ elif field_type == "float":
+ value = float(user_input)
+ else:
+ value = user_input.strip()
+
+ # Set the value
+ if field_name == "take_profit":
+ if "triple_barrier_config" not in config:
+ config["triple_barrier_config"] = GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy()
+ config["triple_barrier_config"]["take_profit"] = value
+ else:
+ config[field_name] = value
+
+ set_controller_config(context, config)
+
+ # Clear field editing state
+ context.user_data.pop("editing_controller_field", None)
+ context.user_data["bots_state"] = "editing_config"
+
+ # Show success
+ await update.message.reply_text(
f"{label} set to: {value}",
parse_mode="HTML"
)
- # If trading_pair was set and we have a connector, fetch market data
- if field_name == "trading_pair" and config.get("connector_name"):
- # Create a fake callback query context for fetch_and_apply_market_data
- keyboard = [[InlineKeyboardButton("Fetching market data...", callback_data="bots:noop")]]
- msg = await update.message.reply_text(
- "Fetching market data\\.\\.\\.",
+ # If trading_pair was set and we have a connector, fetch market data
+ if field_name == "trading_pair" and config.get("connector_name"):
+ # Create a fake callback query context for fetch_and_apply_market_data
+ keyboard = [[InlineKeyboardButton("Fetching market data...", callback_data="bots:noop")]]
+ msg = await update.message.reply_text(
+ "Fetching market data\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
+ try:
+ client = await get_bots_client(chat_id)
+ connector = config.get("connector_name")
+ pair = config.get("trading_pair")
+ side = config.get("side", SIDE_LONG)
+
+ # Fetch current price
+ current_price = await fetch_current_price(client, connector, pair)
+
+ if current_price:
+ # Cache and calculate
+ context.user_data["grid_strike_current_price"] = current_price
+ start, end, limit = calculate_auto_prices(current_price, side)
+ config["start_price"] = start
+ config["end_price"] = end
+ config["limit_price"] = limit
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ config["id"] = generate_config_id(connector, pair, existing_configs=existing_configs)
+ set_controller_config(context, config)
+
+ # Fetch candles
+ candles = await fetch_candles(client, connector, pair, interval="5m", max_records=420)
+
+ if candles:
+ chart_bytes = generate_candles_chart(
+ candles, pair,
+ start_price=start,
+ end_price=end,
+ limit_price=limit,
+ current_price=current_price
+ )
+ await update.message.reply_photo(
+ photo=chart_bytes,
+ caption=(
+ f"*{escape_markdown_v2(pair)}* Grid Zone\n\n"
+ f"Current: `{current_price:,.4f}`\n"
+ f"Start: `{start:,.4f}` \\(\\-2%\\)\n"
+ f"End: `{end:,.4f}` \\(\\+2%\\)\n"
+ f"Limit: `{limit:,.4f}`"
+ ),
+ parse_mode="MarkdownV2"
+ )
+ else:
+ await update.message.reply_text(
+ f"*{escape_markdown_v2(pair)}* prices auto\\-calculated\\.\n\n"
+ f"Current: `{current_price:,.4f}`",
+ parse_mode="MarkdownV2"
+ )
+ else:
+ await update.message.reply_text(
+ f"Could not fetch price for {pair}. Set prices manually."
+ )
+
+ except Exception as e:
+ logger.error(f"Error fetching market data: {e}", exc_info=True)
+ await update.message.reply_text(f"Error fetching market data: {str(e)[:50]}")
+
+ # Show the form again
+ keyboard = [[InlineKeyboardButton("Continue Editing", callback_data="bots:edit_config_back")]]
+ await update.message.reply_text(
+ "Tap to continue editing configuration\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
+ except ValueError as e:
+ await update.message.reply_text(
+ f"Invalid value for {label}. Expected {field_type}. Please try again."
+ )
+
+
+# ============================================
+# SAVE CONFIG
+# ============================================
+
+async def handle_save_config(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Save the current config to the backend"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ # Validate required fields
+ missing = []
+ for field_name in GRID_STRIKE_FIELD_ORDER:
+ field_info = GRID_STRIKE_FIELDS[field_name]
+ if field_info.get("required"):
+ if field_name == "take_profit":
+ value = config.get("triple_barrier_config", {}).get("take_profit")
+ else:
+ value = config.get(field_name)
+
+ if value is None or value == "" or value == 0:
+ missing.append(field_info["label"])
+
+ if missing:
+ missing_str = ", ".join(missing)
+ await query.answer(f"Missing required fields: {missing_str}", show_alert=True)
+ return
+
+ try:
+ client = await get_bots_client(chat_id)
+
+ # Save to backend using config id as the config_name
+ config_name = config.get("id", "")
+ result = await client.controllers.create_or_update_controller_config(config_name, config)
+
+ # Clear state
+ clear_bots_state(context)
+
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_grid_strike")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ config_id = config.get("id", "unknown")
+ await query.message.edit_text(
+ f"*Config Saved\\!*\n\n"
+ f"Controller `{escape_markdown_v2(config_id)}` has been saved successfully\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+
+ except Exception as e:
+ logger.error(f"Error saving config: {e}", exc_info=True)
+ await query.answer(f"Failed to save: {str(e)[:100]}", show_alert=True)
+
+
+# ============================================
+# EDIT EXISTING CONFIG
+# ============================================
+
+async def handle_edit_config(update: Update, context: ContextTypes.DEFAULT_TYPE, config_index: int) -> None:
+ """Load an existing config for editing
+
+ Args:
+ update: Telegram update
+ context: Telegram context
+ config_index: Index in the configs list
+ """
+ query = update.callback_query
+ configs_list = context.user_data.get("controller_configs_list", [])
+
+ if config_index >= len(configs_list):
+ await query.answer("Config not found", show_alert=True)
+ return
+
+ config = configs_list[config_index].copy()
+ set_controller_config(context, config)
+ context.user_data["bots_state"] = "editing_config"
+
+ await show_config_form(update, context)
+
+
+# ============================================
+# DEPLOY CONTROLLERS
+# ============================================
+
+# Default deploy settings
+DEPLOY_DEFAULTS = {
+ "instance_name": "",
+ "credentials_profile": "master_account",
+ "controllers_config": [],
+ "max_global_drawdown_quote": None,
+ "max_controller_drawdown_quote": None,
+ "image": "hummingbot/hummingbot:latest",
+}
+
+# Deploy field configuration for progressive flow
+DEPLOY_FIELDS = {
+ "instance_name": {
+ "label": "Instance Name",
+ "required": True,
+ "hint": "Name for your bot instance (e.g. my_grid_bot)",
+ "type": "str",
+ "default": None,
+ },
+ "credentials_profile": {
+ "label": "Credentials Profile",
+ "required": True,
+ "hint": "Account profile with exchange credentials",
+ "type": "str",
+ "default": "master_account",
+ },
+ "max_global_drawdown_quote": {
+ "label": "Max Global Drawdown",
+ "required": False,
+ "hint": "Maximum total loss in quote currency (e.g. 1000 USDT)",
+ "type": "float",
+ "default": None,
+ },
+ "max_controller_drawdown_quote": {
+ "label": "Max Controller Drawdown",
+ "required": False,
+ "hint": "Maximum loss per controller in quote currency",
+ "type": "float",
+ "default": None,
+ },
+ "image": {
+ "label": "Docker Image",
+ "required": False,
+ "hint": "Hummingbot image to use",
+ "type": "str",
+ "default": "hummingbot/hummingbot:latest",
+ },
+}
+
+# Field order for progressive flow
+DEPLOY_FIELD_ORDER = [
+ "instance_name",
+ "credentials_profile",
+ "max_global_drawdown_quote",
+ "max_controller_drawdown_quote",
+ "image",
+]
+
+
+async def show_deploy_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show the deploy controllers menu"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ try:
+ client = await get_bots_client(chat_id)
+ configs = await client.controllers.list_controller_configs()
+
+ if not configs:
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ await query.message.edit_text(
+ r"*Deploy Controllers*" + "\n\n"
+ r"No configurations available to deploy\." + "\n"
+ r"Create a controller config first\.",
parse_mode="MarkdownV2",
reply_markup=InlineKeyboardMarkup(keyboard)
)
+ return
- try:
- client = await get_bots_client()
- connector = config.get("connector_name")
- pair = config.get("trading_pair")
- side = config.get("side", SIDE_LONG)
+ # Store configs and initialize selection
+ context.user_data["controller_configs_list"] = configs
+ selected = context.user_data.get("selected_controllers", set())
- # Fetch current price
- current_price = await fetch_current_price(client, connector, pair)
+ # Build message
+ lines = [r"*Deploy Controllers*", ""]
+ lines.append(r"Select controllers to deploy:")
+ lines.append("")
- if current_price:
- # Cache and calculate
- context.user_data["grid_strike_current_price"] = current_price
- start, end, limit = calculate_auto_prices(current_price, side)
- config["start_price"] = start
- config["end_price"] = end
- config["limit_price"] = limit
- existing_configs = context.user_data.get("controller_configs_list", [])
- config["id"] = generate_config_id(connector, pair, existing_configs=existing_configs)
- set_controller_config(context, config)
+ # Build keyboard with checkboxes
+ keyboard = []
- # Fetch candles
- candles = await fetch_candles(client, connector, pair, interval="5m", max_records=50)
+ for i, config in enumerate(configs):
+ config_id = config.get("id", config.get("config_name", f"config_{i}"))
+ is_selected = i in selected
+ checkbox = "[x]" if is_selected else "[ ]"
- if candles:
- chart_bytes = generate_candles_chart(
- candles, pair,
- start_price=start,
- end_price=end,
- limit_price=limit,
- current_price=current_price
- )
- await update.message.reply_photo(
- photo=chart_bytes,
- caption=(
- f"*{escape_markdown_v2(pair)}* Grid Zone\n\n"
- f"Current: `{current_price:,.4f}`\n"
- f"Start: `{start:,.4f}` \\(\\-2%\\)\n"
- f"End: `{end:,.4f}` \\(\\+2%\\)\n"
- f"Limit: `{limit:,.4f}`"
- ),
- parse_mode="MarkdownV2"
- )
- else:
- await update.message.reply_text(
- f"*{escape_markdown_v2(pair)}* prices auto\\-calculated\\.\n\n"
- f"Current: `{current_price:,.4f}`",
- parse_mode="MarkdownV2"
- )
- else:
- await update.message.reply_text(
- f"Could not fetch price for {pair}. Set prices manually."
- )
+ keyboard.append([
+ InlineKeyboardButton(
+ f"{checkbox} {config_id[:25]}",
+ callback_data=f"bots:toggle_deploy:{i}"
+ )
+ ])
+
+ # Action buttons
+ keyboard.append([
+ InlineKeyboardButton("Select All", callback_data="bots:select_all"),
+ InlineKeyboardButton("Clear All", callback_data="bots:clear_all"),
+ ])
+
+ if selected:
+ keyboard.append([
+ InlineKeyboardButton(f"Next: Configure ({len(selected)})", callback_data="bots:deploy_configure"),
+ ])
+
+ keyboard.append([
+ InlineKeyboardButton("Back", callback_data="bots:main_menu"),
+ ])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ try:
+ await query.message.edit_text(
+ "\n".join(lines),
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ except BadRequest as e:
+ if "Message is not modified" not in str(e):
+ raise
+
+ except Exception as e:
+ logger.error(f"Error loading deploy menu: {e}", exc_info=True)
+ error_msg = format_error_message(f"Failed to load configs: {str(e)}")
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ await query.message.edit_text(
+ error_msg,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
+
+async def handle_toggle_deploy_selection(update: Update, context: ContextTypes.DEFAULT_TYPE, index: int) -> None:
+ """Toggle selection of a controller for deployment"""
+ selected = context.user_data.get("selected_controllers", set())
+
+ if index in selected:
+ selected.discard(index)
+ else:
+ selected.add(index)
+
+ context.user_data["selected_controllers"] = selected
+ await show_deploy_menu(update, context)
+
+
+async def handle_select_all(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Select all controllers for deployment"""
+ configs = context.user_data.get("controller_configs_list", [])
+ context.user_data["selected_controllers"] = set(range(len(configs)))
+ await show_deploy_menu(update, context)
+
+
+async def handle_clear_all(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Clear all selections"""
+ context.user_data["selected_controllers"] = set()
+ await show_deploy_menu(update, context)
+
+
+async def show_deploy_configure(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Start the streamlined deployment configuration flow"""
+ # Use the new streamlined deploy flow
+ await show_deploy_config_step(update, context)
+
+
+async def show_deploy_form(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show the deployment configuration form with current values"""
+ query = update.callback_query
+ deploy_params = context.user_data.get("deploy_params", DEPLOY_DEFAULTS.copy())
+
+ # Build display
+ lines = [r"*Deploy Configuration*", ""]
+
+ instance = deploy_params.get("instance_name", "") or "Not set"
+ creds = deploy_params.get("credentials_profile", "") or "Not set"
+ controllers = deploy_params.get("controllers_config", [])
+ controllers_str = ", ".join(controllers) if controllers else "None"
+ max_global = deploy_params.get("max_global_drawdown_quote")
+ max_controller = deploy_params.get("max_controller_drawdown_quote")
+ image = deploy_params.get("image", "hummingbot/hummingbot:latest")
+
+ lines.append(f"*Instance Name*\\*: `{escape_markdown_v2(instance)}`")
+ lines.append(f"*Credentials Profile*\\*: `{escape_markdown_v2(creds)}`")
+ lines.append(f"*Controllers*: `{escape_markdown_v2(controllers_str[:50])}`")
+ lines.append(f"*Max Global DD*: `{max_global if max_global else 'Not set'}`")
+ lines.append(f"*Max Controller DD*: `{max_controller if max_controller else 'Not set'}`")
+ lines.append(f"*Image*: `{escape_markdown_v2(image)}`")
+ lines.append("")
+ lines.append(r"_\* \= required_")
- except Exception as e:
- logger.error(f"Error fetching market data: {e}", exc_info=True)
- await update.message.reply_text(f"Error fetching market data: {str(e)[:50]}")
+ # Build keyboard
+ keyboard = [
+ [
+ InlineKeyboardButton("Instance Name", callback_data="bots:deploy_set:instance_name"),
+ InlineKeyboardButton("Credentials", callback_data="bots:deploy_set:credentials_profile"),
+ ],
+ [
+ InlineKeyboardButton("Max Global DD", callback_data="bots:deploy_set:max_global_drawdown_quote"),
+ InlineKeyboardButton("Max Controller DD", callback_data="bots:deploy_set:max_controller_drawdown_quote"),
+ ],
+ [
+ InlineKeyboardButton("Image", callback_data="bots:deploy_set:image"),
+ ],
+ ]
- # Show the form again
- keyboard = [[InlineKeyboardButton("Continue Editing", callback_data="bots:edit_config_back")]]
- await update.message.reply_text(
- "Tap to continue editing configuration\\.",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
+ # Check if ready to deploy
+ can_deploy = bool(deploy_params.get("instance_name") and deploy_params.get("credentials_profile"))
- except ValueError as e:
- await update.message.reply_text(
- f"Invalid value for {label}. Expected {field_type}. Please try again."
- )
+ if can_deploy:
+ keyboard.append([
+ InlineKeyboardButton("Deploy Now", callback_data="bots:execute_deploy"),
+ ])
+
+ keyboard.append([
+ InlineKeyboardButton("Back to Selection", callback_data="bots:deploy_menu"),
+ ])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await query.message.edit_text(
+ "\n".join(lines),
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
# ============================================
-# SAVE CONFIG
+# PROGRESSIVE DEPLOY CONFIGURATION FLOW
# ============================================
-async def handle_save_config(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Save the current config to the backend"""
+async def show_deploy_progressive_form(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show the progressive deployment configuration form"""
query = update.callback_query
- config = get_controller_config(context)
- # Validate required fields
- missing = []
- for field_name in GRID_STRIKE_FIELD_ORDER:
- field_info = GRID_STRIKE_FIELDS[field_name]
- if field_info.get("required"):
- if field_name == "take_profit":
- value = config.get("triple_barrier_config", {}).get("take_profit")
- else:
- value = config.get(field_name)
+ deploy_params = context.user_data.get("deploy_params", DEPLOY_DEFAULTS.copy())
+ current_field = context.user_data.get("deploy_current_field", DEPLOY_FIELD_ORDER[0])
- if value is None or value == "" or value == 0:
- missing.append(field_info["label"])
+ message_text, reply_markup = _build_deploy_progressive_message(
+ deploy_params, current_field, context
+ )
- if missing:
- missing_str = ", ".join(missing)
- await query.answer(f"Missing required fields: {missing_str}", show_alert=True)
- return
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ await query.answer()
- try:
- client = await get_bots_client()
- # Save to backend using config id as the config_name
- config_name = config.get("id", "")
- result = await client.controllers.create_or_update_controller_config(config_name, config)
+def _build_deploy_progressive_message(deploy_params: dict, current_field: str, context) -> tuple:
+ """Build the progressive deploy configuration message."""
+ controllers = deploy_params.get("controllers_config", [])
+ controllers_str = ", ".join(controllers) if controllers else "None"
- # Clear state
- clear_bots_state(context)
+ lines = [r"*Deploy Configuration*", ""]
+ lines.append(f"*Controllers:* `{escape_markdown_v2(controllers_str[:40])}`")
+ lines.append("")
- keyboard = [
- [InlineKeyboardButton("Create Another", callback_data="bots:new_grid_strike")],
- [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
- ]
- reply_markup = InlineKeyboardMarkup(keyboard)
+ for field_name in DEPLOY_FIELD_ORDER:
+ field_info = DEPLOY_FIELDS[field_name]
+ label = field_info["label"]
+ required = "\\*" if field_info.get("required") else ""
+ value = deploy_params.get(field_name)
- config_id = config.get("id", "unknown")
- await query.message.edit_text(
- f"*Config Saved\\!*\n\n"
- f"Controller `{escape_markdown_v2(config_id)}` has been saved successfully\\.",
- parse_mode="MarkdownV2",
- reply_markup=reply_markup
- )
+ if value is not None and value != "":
+ value_display = str(value)
+ if field_name == "credentials_profile" and value == "master_account":
+ value_display = "master_account (default)"
+ else:
+ default = field_info.get("default")
+ value_display = f"{default} (default)" if default else "Not set"
- except Exception as e:
- logger.error(f"Error saving config: {e}", exc_info=True)
- await query.answer(f"Failed to save: {str(e)[:100]}", show_alert=True)
+ if field_name == current_field:
+ lines.append(f"➡️ *{escape_markdown_v2(label)}*{required}: _awaiting input_")
+ elif DEPLOY_FIELD_ORDER.index(field_name) < DEPLOY_FIELD_ORDER.index(current_field):
+ lines.append(f"✅ *{escape_markdown_v2(label)}*{required}: `{escape_markdown_v2(value_display)}`")
+ else:
+ lines.append(f"⬜ *{escape_markdown_v2(label)}*{required}: `{escape_markdown_v2(value_display)}`")
+ field_info = DEPLOY_FIELDS.get(current_field, {})
+ hint = field_info.get("hint", "")
+ if hint:
+ lines.append("")
+ lines.append(f"_Hint: {escape_markdown_v2(hint)}_")
-# ============================================
-# EDIT EXISTING CONFIG
-# ============================================
+ lines.append("")
+ lines.append(r"_Type a value or use the buttons below\._")
-async def handle_edit_config(update: Update, context: ContextTypes.DEFAULT_TYPE, config_index: int) -> None:
- """Load an existing config for editing
+ keyboard = []
+ default_value = DEPLOY_FIELDS.get(current_field, {}).get("default")
+ if default_value:
+ keyboard.append([
+ InlineKeyboardButton(f"Use Default: {default_value[:20]}", callback_data=f"bots:deploy_use_default:{current_field}")
+ ])
- Args:
- update: Telegram update
- context: Telegram context
- config_index: Index in the configs list
- """
- query = update.callback_query
- configs_list = context.user_data.get("controller_configs_list", [])
+ if not DEPLOY_FIELDS.get(current_field, {}).get("required"):
+ keyboard.append([InlineKeyboardButton("Skip (keep default)", callback_data="bots:deploy_skip_field")])
- if config_index >= len(configs_list):
- await query.answer("Config not found", show_alert=True)
+ nav_buttons = []
+ current_index = DEPLOY_FIELD_ORDER.index(current_field)
+ if current_index > 0:
+ nav_buttons.append(InlineKeyboardButton("« Back", callback_data="bots:deploy_prev_field"))
+ nav_buttons.append(InlineKeyboardButton("❌ Cancel", callback_data="bots:deploy_menu"))
+ keyboard.append(nav_buttons)
+
+ return "\n".join(lines), InlineKeyboardMarkup(keyboard)
+
+
+async def handle_deploy_progressive_input(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle text input during progressive deploy configuration"""
+ current_field = context.user_data.get("deploy_current_field")
+ bots_state = context.user_data.get("bots_state")
+
+ if bots_state != "deploy_progressive" or not current_field:
return
- config = configs_list[config_index].copy()
- set_controller_config(context, config)
- context.user_data["bots_state"] = "editing_config"
+ try:
+ await update.message.delete()
+ except:
+ pass
- await show_config_form(update, context)
+ user_input = update.message.text.strip()
+ field_info = DEPLOY_FIELDS.get(current_field, {})
+ field_type = field_info.get("type", "str")
+ try:
+ if field_type == "float":
+ value = float(user_input) if user_input else None
+ elif field_type == "int":
+ value = int(user_input) if user_input else None
+ else:
+ value = user_input
-# ============================================
-# DEPLOY CONTROLLERS
-# ============================================
+ deploy_params = context.user_data.get("deploy_params", DEPLOY_DEFAULTS.copy())
+ deploy_params[current_field] = value
+ context.user_data["deploy_params"] = deploy_params
-# Default deploy settings
-DEPLOY_DEFAULTS = {
- "instance_name": "",
- "credentials_profile": "master_account",
- "controllers_config": [],
- "max_global_drawdown_quote": None,
- "max_controller_drawdown_quote": None,
- "image": "hummingbot/hummingbot:latest",
-}
+ await _advance_deploy_field(update, context)
-# Deploy field configuration for progressive flow
-DEPLOY_FIELDS = {
- "instance_name": {
- "label": "Instance Name",
- "required": True,
- "hint": "Name for your bot instance (e.g. my_grid_bot)",
- "type": "str",
- "default": None,
- },
- "credentials_profile": {
- "label": "Credentials Profile",
- "required": True,
- "hint": "Account profile with exchange credentials",
- "type": "str",
- "default": "master_account",
- },
- "max_global_drawdown_quote": {
- "label": "Max Global Drawdown",
- "required": False,
- "hint": "Maximum total loss in quote currency (e.g. 1000 USDT)",
- "type": "float",
- "default": None,
- },
- "max_controller_drawdown_quote": {
- "label": "Max Controller Drawdown",
- "required": False,
- "hint": "Maximum loss per controller in quote currency",
- "type": "float",
- "default": None,
- },
- "image": {
- "label": "Docker Image",
- "required": False,
- "hint": "Hummingbot image to use",
- "type": "str",
- "default": "hummingbot/hummingbot:latest",
- },
-}
+ except ValueError:
+ import asyncio
+ bot = update.get_bot()
+ chat_id = context.user_data.get("deploy_chat_id", update.effective_chat.id)
+ error_msg = await bot.send_message(chat_id=chat_id, text=f"❌ Invalid value. Please enter a valid {field_type}.")
+ await asyncio.sleep(3)
+ try:
+ await error_msg.delete()
+ except:
+ pass
-# Field order for progressive flow
-DEPLOY_FIELD_ORDER = [
- "instance_name",
- "credentials_profile",
- "max_global_drawdown_quote",
- "max_controller_drawdown_quote",
- "image",
-]
+async def _advance_deploy_field(update: Update, context) -> None:
+ """Advance to the next deploy field or show summary"""
+ current_field = context.user_data.get("deploy_current_field")
+ current_index = DEPLOY_FIELD_ORDER.index(current_field)
-async def show_deploy_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Show the deploy controllers menu"""
- query = update.callback_query
+ if current_index < len(DEPLOY_FIELD_ORDER) - 1:
+ next_field = DEPLOY_FIELD_ORDER[current_index + 1]
+ context.user_data["deploy_current_field"] = next_field
+ await _update_deploy_progressive_message(context, update.get_bot())
+ else:
+ context.user_data["bots_state"] = "deploy_review"
+ context.user_data.pop("deploy_current_field", None)
+ await _show_deploy_summary(context, update.get_bot())
+
+
+async def _update_deploy_progressive_message(context, bot) -> None:
+ """Update the deploy progressive message with current progress"""
+ message_id = context.user_data.get("deploy_message_id")
+ chat_id = context.user_data.get("deploy_chat_id")
+ current_field = context.user_data.get("deploy_current_field")
+ deploy_params = context.user_data.get("deploy_params", {})
+
+ if not message_id or not chat_id:
+ return
+
+ message_text, reply_markup = _build_deploy_progressive_message(deploy_params, current_field, context)
try:
- client = await get_bots_client()
- configs = await client.controllers.list_controller_configs()
+ await bot.edit_message_text(chat_id=chat_id, message_id=message_id, text=message_text, parse_mode="MarkdownV2", reply_markup=reply_markup)
+ except Exception as e:
+ logger.error(f"Error updating deploy message: {e}")
- if not configs:
- keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
- await query.message.edit_text(
- r"*Deploy Controllers*" + "\n\n"
- r"No configurations available to deploy\." + "\n"
- r"Create a controller config first\.",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- return
- # Store configs and initialize selection
- context.user_data["controller_configs_list"] = configs
- selected = context.user_data.get("selected_controllers", set())
+async def _show_deploy_summary(context, bot) -> None:
+ """Show deployment summary before executing"""
+ message_id = context.user_data.get("deploy_message_id")
+ chat_id = context.user_data.get("deploy_chat_id")
+ deploy_params = context.user_data.get("deploy_params", {})
- # Build message
- lines = [r"*Deploy Controllers*", ""]
- lines.append(r"Select controllers to deploy:")
- lines.append("")
+ if not message_id or not chat_id:
+ return
- # Build keyboard with checkboxes
- keyboard = []
+ controllers = deploy_params.get("controllers_config", [])
+ controllers_str = ", ".join(controllers) if controllers else "None"
- for i, config in enumerate(configs):
- config_id = config.get("id", config.get("config_name", f"config_{i}"))
- is_selected = i in selected
- checkbox = "[x]" if is_selected else "[ ]"
+ lines = [r"*Deploy Configuration \- Review*", ""]
+ lines.append(f"*Controllers:* `{escape_markdown_v2(controllers_str)}`")
+ lines.append("")
- keyboard.append([
- InlineKeyboardButton(
- f"{checkbox} {config_id[:25]}",
- callback_data=f"bots:toggle_deploy:{i}"
- )
- ])
+ for field_name in DEPLOY_FIELD_ORDER:
+ field_info = DEPLOY_FIELDS[field_name]
+ label = field_info["label"]
+ required = "\\*" if field_info.get("required") else ""
+ value = deploy_params.get(field_name)
- # Action buttons
- keyboard.append([
- InlineKeyboardButton("Select All", callback_data="bots:select_all"),
- InlineKeyboardButton("Clear All", callback_data="bots:clear_all"),
- ])
+ if value is not None and value != "":
+ value_display = str(value)
+ else:
+ default = field_info.get("default")
+ if default:
+ deploy_params[field_name] = default
+ value_display = str(default)
+ else:
+ value_display = "Not set"
- if selected:
- keyboard.append([
- InlineKeyboardButton(f"Next: Configure ({len(selected)})", callback_data="bots:deploy_configure"),
- ])
+ lines.append(f"✅ *{escape_markdown_v2(label)}*{required}: `{escape_markdown_v2(value_display)}`")
- keyboard.append([
- InlineKeyboardButton("Back", callback_data="bots:main_menu"),
- ])
+ context.user_data["deploy_params"] = deploy_params
- reply_markup = InlineKeyboardMarkup(keyboard)
+ lines.append("")
+ lines.append(r"_Ready to deploy\? Tap Deploy Now or edit any field\._")
- try:
- await query.message.edit_text(
- "\n".join(lines),
- parse_mode="MarkdownV2",
- reply_markup=reply_markup
- )
- except BadRequest as e:
- if "Message is not modified" not in str(e):
- raise
+ keyboard = []
+ field_buttons = []
+ for field_name in DEPLOY_FIELD_ORDER:
+ label = DEPLOY_FIELDS[field_name]["label"]
+ field_buttons.append(InlineKeyboardButton(f"✏️ {label[:15]}", callback_data=f"bots:deploy_edit:{field_name}"))
+
+ for i in range(0, len(field_buttons), 2):
+ keyboard.append(field_buttons[i:i+2])
+
+ keyboard.append([InlineKeyboardButton("🚀 Deploy Now", callback_data="bots:execute_deploy")])
+ keyboard.append([InlineKeyboardButton("« Back to Selection", callback_data="bots:deploy_menu")])
+ try:
+ await bot.edit_message_text(chat_id=chat_id, message_id=message_id, text="\n".join(lines), parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard))
except Exception as e:
- logger.error(f"Error loading deploy menu: {e}", exc_info=True)
- error_msg = format_error_message(f"Failed to load configs: {str(e)}")
- keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
- await query.message.edit_text(
- error_msg,
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
+ logger.error(f"Error showing deploy summary: {e}")
-async def handle_toggle_deploy_selection(update: Update, context: ContextTypes.DEFAULT_TYPE, index: int) -> None:
- """Toggle selection of a controller for deployment"""
- selected = context.user_data.get("selected_controllers", set())
+async def handle_deploy_use_default(update: Update, context: ContextTypes.DEFAULT_TYPE, field_name: str) -> None:
+ """Use default value for a deploy field"""
+ query = update.callback_query
+ field_info = DEPLOY_FIELDS.get(field_name, {})
+ default = field_info.get("default")
- if index in selected:
- selected.discard(index)
- else:
- selected.add(index)
+ if default:
+ deploy_params = context.user_data.get("deploy_params", DEPLOY_DEFAULTS.copy())
+ deploy_params[field_name] = default
+ context.user_data["deploy_params"] = deploy_params
- context.user_data["selected_controllers"] = selected
- await show_deploy_menu(update, context)
+ await _advance_deploy_field(update, context)
+ await query.answer()
-async def handle_select_all(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Select all controllers for deployment"""
- configs = context.user_data.get("controller_configs_list", [])
- context.user_data["selected_controllers"] = set(range(len(configs)))
- await show_deploy_menu(update, context)
+async def handle_deploy_skip_field(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Skip the current optional deploy field"""
+ query = update.callback_query
+ current_field = context.user_data.get("deploy_current_field")
+ field_info = DEPLOY_FIELDS.get(current_field, {})
+ default = field_info.get("default")
+ deploy_params = context.user_data.get("deploy_params", DEPLOY_DEFAULTS.copy())
+ deploy_params[current_field] = default
+ context.user_data["deploy_params"] = deploy_params
-async def handle_clear_all(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Clear all selections"""
- context.user_data["selected_controllers"] = set()
- await show_deploy_menu(update, context)
+ await _advance_deploy_field(update, context)
+ await query.answer()
-async def show_deploy_configure(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Start the streamlined deployment configuration flow"""
- # Use the new streamlined deploy flow
- await show_deploy_config_step(update, context)
+async def handle_deploy_prev_field(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Go back to the previous deploy field"""
+ query = update.callback_query
+ current_field = context.user_data.get("deploy_current_field")
+ current_index = DEPLOY_FIELD_ORDER.index(current_field)
+ if current_index > 0:
+ prev_field = DEPLOY_FIELD_ORDER[current_index - 1]
+ context.user_data["deploy_current_field"] = prev_field
+ await show_deploy_progressive_form(update, context)
+ else:
+ await query.answer("Already at first field")
-async def show_deploy_form(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Show the deployment configuration form with current values"""
+
+async def handle_deploy_edit_field(update: Update, context: ContextTypes.DEFAULT_TYPE, field_name: str) -> None:
+ """Edit a specific field from the summary view"""
query = update.callback_query
- deploy_params = context.user_data.get("deploy_params", DEPLOY_DEFAULTS.copy())
+ context.user_data["deploy_current_field"] = field_name
+ context.user_data["bots_state"] = "deploy_progressive"
+ await show_deploy_progressive_form(update, context)
- # Build display
- lines = [r"*Deploy Configuration*", ""]
- instance = deploy_params.get("instance_name", "") or "Not set"
- creds = deploy_params.get("credentials_profile", "") or "Not set"
- controllers = deploy_params.get("controllers_config", [])
- controllers_str = ", ".join(controllers) if controllers else "None"
- max_global = deploy_params.get("max_global_drawdown_quote")
- max_controller = deploy_params.get("max_controller_drawdown_quote")
- image = deploy_params.get("image", "hummingbot/hummingbot:latest")
+async def handle_deploy_set_field(update: Update, context: ContextTypes.DEFAULT_TYPE, field_name: str) -> None:
+ """Prompt user to enter a value for a deploy field"""
+ query = update.callback_query
- lines.append(f"*Instance Name*\\*: `{escape_markdown_v2(instance)}`")
- lines.append(f"*Credentials Profile*\\*: `{escape_markdown_v2(creds)}`")
- lines.append(f"*Controllers*: `{escape_markdown_v2(controllers_str[:50])}`")
- lines.append(f"*Max Global DD*: `{max_global if max_global else 'Not set'}`")
- lines.append(f"*Max Controller DD*: `{max_controller if max_controller else 'Not set'}`")
- lines.append(f"*Image*: `{escape_markdown_v2(image)}`")
- lines.append("")
- lines.append(r"_\* \= required_")
+ labels = {
+ "instance_name": "Instance Name",
+ "credentials_profile": "Credentials Profile",
+ "max_global_drawdown_quote": "Max Global Drawdown (Quote)",
+ "max_controller_drawdown_quote": "Max Controller Drawdown (Quote)",
+ "image": "Docker Image",
+ }
- # Build keyboard
- keyboard = [
- [
- InlineKeyboardButton("Instance Name", callback_data="bots:deploy_set:instance_name"),
- InlineKeyboardButton("Credentials", callback_data="bots:deploy_set:credentials_profile"),
- ],
- [
- InlineKeyboardButton("Max Global DD", callback_data="bots:deploy_set:max_global_drawdown_quote"),
- InlineKeyboardButton("Max Controller DD", callback_data="bots:deploy_set:max_controller_drawdown_quote"),
- ],
- [
- InlineKeyboardButton("Image", callback_data="bots:deploy_set:image"),
- ],
- ]
+ hints = {
+ "instance_name": "e.g. my_grid_bot",
+ "credentials_profile": "e.g. binance_main",
+ "max_global_drawdown_quote": "e.g. 1000 (in USDT)",
+ "max_controller_drawdown_quote": "e.g. 500 (in USDT)",
+ "image": "e.g. hummingbot/hummingbot:latest",
+ }
- # Check if ready to deploy
- can_deploy = bool(deploy_params.get("instance_name") and deploy_params.get("credentials_profile"))
+ label = labels.get(field_name, field_name)
+ hint = hints.get(field_name, "")
- if can_deploy:
- keyboard.append([
- InlineKeyboardButton("Deploy Now", callback_data="bots:execute_deploy"),
- ])
+ # Set state for text input
+ context.user_data["bots_state"] = f"deploy_set:{field_name}"
+ context.user_data["editing_deploy_field"] = field_name
- keyboard.append([
- InlineKeyboardButton("Back to Selection", callback_data="bots:deploy_menu"),
- ])
+ # Get current value
+ deploy_params = context.user_data.get("deploy_params", {})
+ current = deploy_params.get(field_name, "")
+ current_str = str(current) if current else "Not set"
+
+ message = (
+ f"*Set {escape_markdown_v2(label)}*\n\n"
+ f"Current: `{escape_markdown_v2(current_str)}`\n\n"
+ )
+
+ if hint:
+ message += f"_Hint: {escape_markdown_v2(hint)}_\n\n"
+
+ message += r"Type the new value or tap Cancel\."
+ keyboard = [[InlineKeyboardButton("Cancel", callback_data="bots:deploy_form_back")]]
reply_markup = InlineKeyboardMarkup(keyboard)
await query.message.edit_text(
- "\n".join(lines),
+ message,
parse_mode="MarkdownV2",
reply_markup=reply_markup
)
-# ============================================
-# PROGRESSIVE DEPLOY CONFIGURATION FLOW
-# ============================================
+async def process_deploy_field_input(update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str) -> None:
+ """Process user input for a deploy field"""
+ field_name = context.user_data.get("editing_deploy_field")
-async def show_deploy_progressive_form(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Show the progressive deployment configuration form"""
- query = update.callback_query
+ if not field_name:
+ await update.message.reply_text("No field selected. Please try again.")
+ return
deploy_params = context.user_data.get("deploy_params", DEPLOY_DEFAULTS.copy())
- current_field = context.user_data.get("deploy_current_field", DEPLOY_FIELD_ORDER[0])
- message_text, reply_markup = _build_deploy_progressive_message(
- deploy_params, current_field, context
- )
+ try:
+ # Parse the value based on field type
+ if field_name in ["max_global_drawdown_quote", "max_controller_drawdown_quote"]:
+ value = float(user_input) if user_input.strip() else None
+ else:
+ value = user_input.strip()
+
+ # Set the value
+ deploy_params[field_name] = value
+ context.user_data["deploy_params"] = deploy_params
+
+ # Clear field editing state
+ context.user_data.pop("editing_deploy_field", None)
+ context.user_data["bots_state"] = "deploy_configure"
+
+ # Show confirmation
+ label = field_name.replace("_", " ").title()
+ await update.message.reply_text(f"{label} set to: {value}")
+
+ # Show button to return to form
+ keyboard = [[InlineKeyboardButton("Continue", callback_data="bots:deploy_form_back")]]
+ await update.message.reply_text(
+ "Value updated\\. Tap to continue\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
+ except ValueError as e:
+ await update.message.reply_text(f"Invalid value. Please enter a valid number.")
+
+
+async def handle_execute_deploy(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Execute the deployment of selected controllers"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ deploy_params = context.user_data.get("deploy_params", {})
+
+ instance_name = deploy_params.get("instance_name")
+ credentials_profile = deploy_params.get("credentials_profile")
+ controllers_config = deploy_params.get("controllers_config", [])
+
+ if not instance_name or not credentials_profile:
+ await query.answer("Instance name and credentials are required", show_alert=True)
+ return
+
+ if not controllers_config:
+ await query.answer("No controllers selected", show_alert=True)
+ return
+ # Show deploying message FIRST (before the long operation)
+ controllers_str = ", ".join([f"`{escape_markdown_v2(c)}`" for c in controllers_config])
await query.message.edit_text(
- message_text,
- parse_mode="MarkdownV2",
- reply_markup=reply_markup
+ f"*Deploying\\.\\.\\.*\n\n"
+ f"*Instance:* `{escape_markdown_v2(instance_name)}`\n"
+ f"*Controllers:*\n{controllers_str}\n\n"
+ f"Please wait, this may take a moment\\.\\.\\.",
+ parse_mode="MarkdownV2"
)
- await query.answer()
+ try:
+ client = await get_bots_client(chat_id)
-def _build_deploy_progressive_message(deploy_params: dict, current_field: str, context) -> tuple:
- """Build the progressive deploy configuration message."""
- controllers = deploy_params.get("controllers_config", [])
- controllers_str = ", ".join(controllers) if controllers else "None"
+ # Deploy using deploy_v2_controllers (this can take time)
+ result = await client.bot_orchestration.deploy_v2_controllers(
+ instance_name=instance_name,
+ credentials_profile=credentials_profile,
+ controllers_config=controllers_config,
+ max_global_drawdown_quote=deploy_params.get("max_global_drawdown_quote"),
+ max_controller_drawdown_quote=deploy_params.get("max_controller_drawdown_quote"),
+ image=deploy_params.get("image", "hummingbot/hummingbot:latest"),
+ )
- lines = [r"*Deploy Configuration*", ""]
- lines.append(f"*Controllers:* `{escape_markdown_v2(controllers_str[:40])}`")
- lines.append("")
+ # Clear deploy state
+ context.user_data.pop("selected_controllers", None)
+ context.user_data.pop("deploy_params", None)
+ context.user_data.pop("bots_state", None)
- for field_name in DEPLOY_FIELD_ORDER:
- field_info = DEPLOY_FIELDS[field_name]
- label = field_info["label"]
- required = "\\*" if field_info.get("required") else ""
- value = deploy_params.get(field_name)
+ keyboard = [
+ [InlineKeyboardButton("View Bots", callback_data="bots:main_menu")],
+ [InlineKeyboardButton("Deploy More", callback_data="bots:deploy_menu")],
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
- if value is not None and value != "":
- value_display = str(value)
- if field_name == "credentials_profile" and value == "master_account":
- value_display = "master_account (default)"
- else:
- default = field_info.get("default")
- value_display = f"{default} (default)" if default else "Not set"
+ status = result.get("status", "unknown")
+ message = result.get("message", "")
- if field_name == current_field:
- lines.append(f"➡️ *{escape_markdown_v2(label)}*{required}: _awaiting input_")
- elif DEPLOY_FIELD_ORDER.index(field_name) < DEPLOY_FIELD_ORDER.index(current_field):
- lines.append(f"✅ *{escape_markdown_v2(label)}*{required}: `{escape_markdown_v2(value_display)}`")
+ # Check for success - either status is "success" or message indicates success
+ is_success = (
+ status == "success" or
+ "successfully" in message.lower() or
+ "created" in message.lower()
+ )
+
+ if is_success:
+ await query.message.edit_text(
+ f"*Deployment Started\\!*\n\n"
+ f"*Instance:* `{escape_markdown_v2(instance_name)}`\n"
+ f"*Controllers:*\n{controllers_str}\n\n"
+ f"The bot is being deployed\\. Check status in Bots menu\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
else:
- lines.append(f"⬜ *{escape_markdown_v2(label)}*{required}: `{escape_markdown_v2(value_display)}`")
+ error_msg = message or "Unknown error"
+ await query.message.edit_text(
+ f"*Deployment Failed*\n\n"
+ f"Error: {escape_markdown_v2(error_msg)}",
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
- field_info = DEPLOY_FIELDS.get(current_field, {})
- hint = field_info.get("hint", "")
- if hint:
- lines.append("")
- lines.append(f"_Hint: {escape_markdown_v2(hint)}_")
+ except Exception as e:
+ logger.error(f"Error deploying controllers: {e}", exc_info=True)
+ # Use message edit instead of query.answer (which may have expired)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:execute_deploy")],
+ [InlineKeyboardButton("Back", callback_data="bots:deploy_form_back")],
+ ]
+ await query.message.edit_text(
+ f"*Deployment Failed*\n\n"
+ f"Error: {escape_markdown_v2(str(e)[:200])}",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- lines.append("")
- lines.append(r"_Type a value or use the buttons below\._")
- keyboard = []
- default_value = DEPLOY_FIELDS.get(current_field, {}).get("default")
- if default_value:
- keyboard.append([
- InlineKeyboardButton(f"Use Default: {default_value[:20]}", callback_data=f"bots:deploy_use_default:{current_field}")
- ])
+# ============================================
+# STREAMLINED DEPLOY FLOW
+# ============================================
- if not DEPLOY_FIELDS.get(current_field, {}).get("required"):
- keyboard.append([InlineKeyboardButton("Skip (keep default)", callback_data="bots:deploy_skip_field")])
+# Available docker images
+AVAILABLE_IMAGES = [
+ "hummingbot/hummingbot:latest",
+ "hummingbot/hummingbot:development",
+]
- nav_buttons = []
- current_index = DEPLOY_FIELD_ORDER.index(current_field)
- if current_index > 0:
- nav_buttons.append(InlineKeyboardButton("« Back", callback_data="bots:deploy_prev_field"))
- nav_buttons.append(InlineKeyboardButton("❌ Cancel", callback_data="bots:deploy_menu"))
- keyboard.append(nav_buttons)
- return "\n".join(lines), InlineKeyboardMarkup(keyboard)
+async def _get_available_credentials(client) -> List[str]:
+ """Fetch list of available credential profiles from the backend"""
+ try:
+ accounts = await client.accounts.list_accounts()
+ return accounts if accounts else ["master_account"]
+ except Exception as e:
+ logger.warning(f"Could not fetch accounts, using default: {e}")
+ return ["master_account"]
+
+
+async def show_deploy_config_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show streamlined deploy configuration with clickable buttons for name, credentials, and image"""
+ query = update.callback_query
+
+ selected = context.user_data.get("selected_controllers", set())
+ configs = context.user_data.get("controller_configs_list", [])
+
+ if not selected:
+ await query.answer("No controllers selected", show_alert=True)
+ return
+
+ # Get selected config names
+ controller_names = [
+ configs[i].get("id", configs[i].get("config_name", f"config_{i}"))
+ for i in selected if i < len(configs)
+ ]
+
+ # Initialize or get deploy params
+ deploy_params = context.user_data.get("deploy_params", {})
+ if not deploy_params.get("controllers_config"):
+ creds_default = "master_account"
+ deploy_params = {
+ "controllers_config": controller_names,
+ "credentials_profile": creds_default,
+ "image": "hummingbot/hummingbot:latest",
+ "instance_name": creds_default, # Default name = credentials profile
+ }
+ context.user_data["deploy_params"] = deploy_params
+ context.user_data["deploy_message_id"] = query.message.message_id
+ context.user_data["deploy_chat_id"] = query.message.chat_id
+
+ # Build message
+ creds = deploy_params.get("credentials_profile", "master_account")
+ image = deploy_params.get("image", "hummingbot/hummingbot:latest")
+ instance_name = deploy_params.get("instance_name", creds)
+ # Build controllers list in code block for readability
+ controllers_block = "\n".join(controller_names)
+ image_short = image.split("/")[-1] if "/" in image else image
-async def handle_deploy_progressive_input(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle text input during progressive deploy configuration"""
- current_field = context.user_data.get("deploy_current_field")
- bots_state = context.user_data.get("bots_state")
+ lines = [
+ r"*🚀 Deploy Controllers*",
+ "",
+ "```",
+ controllers_block,
+ "```",
+ "",
+ f"*Name:* `{escape_markdown_v2(instance_name)}`",
+ f"*Account:* `{escape_markdown_v2(creds)}`",
+ f"*Image:* `{escape_markdown_v2(image_short)}`",
+ "",
+ r"_Tap buttons below to change settings_",
+ ]
- if bots_state != "deploy_progressive" or not current_field:
- return
+ # Build keyboard - one button per row for better readability
+ keyboard = [
+ [InlineKeyboardButton(f"📝 Name: {instance_name[:25]}", callback_data="bots:select_name:_show")],
+ [InlineKeyboardButton(f"👤 Account: {creds}", callback_data="bots:select_creds:_show")],
+ [InlineKeyboardButton(f"🐳 Image: {image_short}", callback_data="bots:select_image:_show")],
+ [InlineKeyboardButton("✅ Deploy Now", callback_data="bots:execute_deploy")],
+ [InlineKeyboardButton("« Back", callback_data="bots:deploy_menu")],
+ ]
- try:
- await update.message.delete()
- except:
- pass
+ reply_markup = InlineKeyboardMarkup(keyboard)
- user_input = update.message.text.strip()
- field_info = DEPLOY_FIELDS.get(current_field, {})
- field_type = field_info.get("type", "str")
+ # Set drawdowns to None (skip them)
+ deploy_params["max_global_drawdown_quote"] = None
+ deploy_params["max_controller_drawdown_quote"] = None
+ context.user_data["deploy_params"] = deploy_params
- try:
- if field_type == "float":
- value = float(user_input) if user_input else None
- elif field_type == "int":
- value = int(user_input) if user_input else None
- else:
- value = user_input
+ await query.message.edit_text(
+ "\n".join(lines),
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
- deploy_params = context.user_data.get("deploy_params", DEPLOY_DEFAULTS.copy())
- deploy_params[current_field] = value
- context.user_data["deploy_params"] = deploy_params
- await _advance_deploy_field(update, context)
+async def handle_select_credentials(update: Update, context: ContextTypes.DEFAULT_TYPE, creds: str) -> None:
+ """Handle credentials profile selection"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
- except ValueError:
- import asyncio
- bot = update.get_bot()
- chat_id = context.user_data.get("deploy_chat_id", update.effective_chat.id)
- error_msg = await bot.send_message(chat_id=chat_id, text=f"❌ Invalid value. Please enter a valid {field_type}.")
- await asyncio.sleep(3)
+ if creds == "_show":
+ # Show available credentials profiles
try:
- await error_msg.delete()
- except:
- pass
-
-
-async def _advance_deploy_field(update: Update, context) -> None:
- """Advance to the next deploy field or show summary"""
- current_field = context.user_data.get("deploy_current_field")
- current_index = DEPLOY_FIELD_ORDER.index(current_field)
-
- if current_index < len(DEPLOY_FIELD_ORDER) - 1:
- next_field = DEPLOY_FIELD_ORDER[current_index + 1]
- context.user_data["deploy_current_field"] = next_field
- await _update_deploy_progressive_message(context, update.get_bot())
- else:
- context.user_data["bots_state"] = "deploy_review"
- context.user_data.pop("deploy_current_field", None)
- await _show_deploy_summary(context, update.get_bot())
-
+ client = await get_bots_client(chat_id)
+ available_creds = await _get_available_credentials(client)
+ except Exception:
+ available_creds = ["master_account"]
-async def _update_deploy_progressive_message(context, bot) -> None:
- """Update the deploy progressive message with current progress"""
- message_id = context.user_data.get("deploy_message_id")
- chat_id = context.user_data.get("deploy_chat_id")
- current_field = context.user_data.get("deploy_current_field")
- deploy_params = context.user_data.get("deploy_params", {})
+ deploy_params = context.user_data.get("deploy_params", {})
+ current = deploy_params.get("credentials_profile", "master_account")
- if not message_id or not chat_id:
- return
+ lines = [
+ r"*Select Credentials Profile*",
+ "",
+ f"Current: `{escape_markdown_v2(current)}`",
+ "",
+ r"_Choose an account to deploy with:_",
+ ]
- message_text, reply_markup = _build_deploy_progressive_message(deploy_params, current_field, context)
+ # Build buttons for each credential profile
+ keyboard = []
+ for acc in available_creds:
+ marker = "✓ " if acc == current else ""
+ keyboard.append([
+ InlineKeyboardButton(f"{marker}{acc}", callback_data=f"bots:select_creds:{acc}")
+ ])
- try:
- await bot.edit_message_text(chat_id=chat_id, message_id=message_id, text=message_text, parse_mode="MarkdownV2", reply_markup=reply_markup)
- except Exception as e:
- logger.error(f"Error updating deploy message: {e}")
+ keyboard.append([
+ InlineKeyboardButton("« Back", callback_data="bots:deploy_config"),
+ ])
+ await query.message.edit_text(
+ "\n".join(lines),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ else:
+ # Set the selected credential profile
+ deploy_params = context.user_data.get("deploy_params", {})
+ deploy_params["credentials_profile"] = creds
+ context.user_data["deploy_params"] = deploy_params
-async def _show_deploy_summary(context, bot) -> None:
- """Show deployment summary before executing"""
- message_id = context.user_data.get("deploy_message_id")
- chat_id = context.user_data.get("deploy_chat_id")
- deploy_params = context.user_data.get("deploy_params", {})
+ await query.answer(f"Account set to {creds}")
+ await show_deploy_config_step(update, context)
- if not message_id or not chat_id:
- return
- controllers = deploy_params.get("controllers_config", [])
- controllers_str = ", ".join(controllers) if controllers else "None"
+async def handle_select_image(update: Update, context: ContextTypes.DEFAULT_TYPE, image: str) -> None:
+ """Handle docker image selection"""
+ query = update.callback_query
- lines = [r"*Deploy Configuration \- Review*", ""]
- lines.append(f"*Controllers:* `{escape_markdown_v2(controllers_str)}`")
- lines.append("")
+ if image == "_show":
+ # Show available images
+ deploy_params = context.user_data.get("deploy_params", {})
+ current = deploy_params.get("image", "hummingbot/hummingbot:latest")
- for field_name in DEPLOY_FIELD_ORDER:
- field_info = DEPLOY_FIELDS[field_name]
- label = field_info["label"]
- required = "\\*" if field_info.get("required") else ""
- value = deploy_params.get(field_name)
+ lines = [
+ r"*Select Docker Image*",
+ "",
+ f"Current: `{escape_markdown_v2(current)}`",
+ "",
+ r"_Choose an image to deploy with:_",
+ ]
- if value is not None and value != "":
- value_display = str(value)
- else:
- default = field_info.get("default")
- if default:
- deploy_params[field_name] = default
- value_display = str(default)
- else:
- value_display = "Not set"
+ # Build buttons for each image
+ keyboard = []
+ for img in AVAILABLE_IMAGES:
+ marker = "✓ " if img == current else ""
+ img_short = img.split("/")[-1] if "/" in img else img
+ keyboard.append([
+ InlineKeyboardButton(f"{marker}{img_short}", callback_data=f"bots:select_image:{img}")
+ ])
- lines.append(f"✅ *{escape_markdown_v2(label)}*{required}: `{escape_markdown_v2(value_display)}`")
+ keyboard.append([
+ InlineKeyboardButton("« Back", callback_data="bots:deploy_config"),
+ ])
- context.user_data["deploy_params"] = deploy_params
+ await query.message.edit_text(
+ "\n".join(lines),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ else:
+ # Set the selected image
+ deploy_params = context.user_data.get("deploy_params", {})
+ deploy_params["image"] = image
+ context.user_data["deploy_params"] = deploy_params
- lines.append("")
- lines.append(r"_Ready to deploy\? Tap Deploy Now or edit any field\._")
+ img_short = image.split("/")[-1] if "/" in image else image
+ await query.answer(f"Image set to {img_short}")
+ await show_deploy_config_step(update, context)
- keyboard = []
- field_buttons = []
- for field_name in DEPLOY_FIELD_ORDER:
- label = DEPLOY_FIELDS[field_name]["label"]
- field_buttons.append(InlineKeyboardButton(f"✏️ {label[:15]}", callback_data=f"bots:deploy_edit:{field_name}"))
- for i in range(0, len(field_buttons), 2):
- keyboard.append(field_buttons[i:i+2])
+async def handle_select_instance_name(update: Update, context: ContextTypes.DEFAULT_TYPE, name: str) -> None:
+ """Handle instance name selection/editing"""
+ query = update.callback_query
- keyboard.append([InlineKeyboardButton("🚀 Deploy Now", callback_data="bots:execute_deploy")])
- keyboard.append([InlineKeyboardButton("« Back to Selection", callback_data="bots:deploy_menu")])
+ if name == "_show":
+ # Show name editing prompt
+ deploy_params = context.user_data.get("deploy_params", {})
+ creds = deploy_params.get("credentials_profile", "master_account")
+ current = deploy_params.get("instance_name", creds)
- try:
- await bot.edit_message_text(chat_id=chat_id, message_id=message_id, text="\n".join(lines), parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard))
- except Exception as e:
- logger.error(f"Error showing deploy summary: {e}")
+ lines = [
+ r"*Edit Instance Name*",
+ "",
+ f"Current: `{escape_markdown_v2(current)}`",
+ "",
+ r"_Send a new name or choose an option:_",
+ ]
+ keyboard = [
+ [InlineKeyboardButton(f"✓ Use: {creds}", callback_data=f"bots:select_name:{creds}")],
+ [InlineKeyboardButton("« Back", callback_data="bots:deploy_config")],
+ ]
-async def handle_deploy_use_default(update: Update, context: ContextTypes.DEFAULT_TYPE, field_name: str) -> None:
- """Use default value for a deploy field"""
- query = update.callback_query
- field_info = DEPLOY_FIELDS.get(field_name, {})
- default = field_info.get("default")
+ await query.message.edit_text(
+ "\n".join(lines),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- if default:
- deploy_params = context.user_data.get("deploy_params", DEPLOY_DEFAULTS.copy())
- deploy_params[field_name] = default
+ # Set state to allow custom name input
+ context.user_data["bots_state"] = "deploy_edit_name"
+ else:
+ # Set the selected name
+ deploy_params = context.user_data.get("deploy_params", {})
+ deploy_params["instance_name"] = name
context.user_data["deploy_params"] = deploy_params
+ context.user_data["bots_state"] = None
- await _advance_deploy_field(update, context)
- await query.answer()
+ await query.answer(f"Name set to {name[:25]}")
+ await show_deploy_config_step(update, context)
-async def handle_deploy_skip_field(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Skip the current optional deploy field"""
- query = update.callback_query
- current_field = context.user_data.get("deploy_current_field")
- field_info = DEPLOY_FIELDS.get(current_field, {})
- default = field_info.get("default")
+async def process_instance_name_input(update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str) -> None:
+ """Process custom instance name input from user text message"""
+ try:
+ await update.message.delete()
+ except:
+ pass
- deploy_params = context.user_data.get("deploy_params", DEPLOY_DEFAULTS.copy())
- deploy_params[current_field] = default
+ custom_name = user_input.strip()
+ if not custom_name:
+ return
+
+ # Set the custom name
+ deploy_params = context.user_data.get("deploy_params", {})
+ deploy_params["instance_name"] = custom_name
context.user_data["deploy_params"] = deploy_params
+ context.user_data["bots_state"] = None
- await _advance_deploy_field(update, context)
- await query.answer()
+ # Update the config step message
+ message_id = context.user_data.get("deploy_message_id")
+ chat_id = context.user_data.get("deploy_chat_id")
+ if message_id and chat_id:
+ # Create a fake update/query to reuse show_deploy_config_step logic
+ # We need to update the existing message, so we'll do it manually
+ creds = deploy_params.get("credentials_profile", "master_account")
+ image = deploy_params.get("image", "hummingbot/hummingbot:latest")
+ controllers = deploy_params.get("controllers_config", [])
-async def handle_deploy_prev_field(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Go back to the previous deploy field"""
- query = update.callback_query
- current_field = context.user_data.get("deploy_current_field")
- current_index = DEPLOY_FIELD_ORDER.index(current_field)
+ controllers_block = "\n".join(controllers)
+ image_short = image.split("/")[-1] if "/" in image else image
- if current_index > 0:
- prev_field = DEPLOY_FIELD_ORDER[current_index - 1]
- context.user_data["deploy_current_field"] = prev_field
- await show_deploy_progressive_form(update, context)
- else:
- await query.answer("Already at first field")
+ lines = [
+ r"*🚀 Deploy Controllers*",
+ "",
+ "```",
+ controllers_block,
+ "```",
+ "",
+ f"*Name:* `{escape_markdown_v2(custom_name)}`",
+ f"*Account:* `{escape_markdown_v2(creds)}`",
+ f"*Image:* `{escape_markdown_v2(image_short)}`",
+ "",
+ r"_Tap buttons below to change settings_",
+ ]
+ keyboard = [
+ [InlineKeyboardButton(f"📝 Name: {custom_name[:25]}", callback_data="bots:select_name:_show")],
+ [InlineKeyboardButton(f"👤 Account: {creds}", callback_data="bots:select_creds:_show")],
+ [InlineKeyboardButton(f"🐳 Image: {image_short}", callback_data="bots:select_image:_show")],
+ [InlineKeyboardButton("✅ Deploy Now", callback_data="bots:execute_deploy")],
+ [InlineKeyboardButton("« Back", callback_data="bots:deploy_menu")],
+ ]
-async def handle_deploy_edit_field(update: Update, context: ContextTypes.DEFAULT_TYPE, field_name: str) -> None:
- """Edit a specific field from the summary view"""
- query = update.callback_query
- context.user_data["deploy_current_field"] = field_name
- context.user_data["bots_state"] = "deploy_progressive"
- await show_deploy_progressive_form(update, context)
+ try:
+ await update.get_bot().edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text="\n".join(lines),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ except Exception as e:
+ logger.error(f"Error updating deploy config message: {e}")
-async def handle_deploy_set_field(update: Update, context: ContextTypes.DEFAULT_TYPE, field_name: str) -> None:
- """Prompt user to enter a value for a deploy field"""
+async def handle_deploy_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show deployment confirmation with auto-generated instance name"""
query = update.callback_query
- labels = {
- "instance_name": "Instance Name",
- "credentials_profile": "Credentials Profile",
- "max_global_drawdown_quote": "Max Global Drawdown (Quote)",
- "max_controller_drawdown_quote": "Max Controller Drawdown (Quote)",
- "image": "Docker Image",
- }
+ deploy_params = context.user_data.get("deploy_params", {})
+ controllers = deploy_params.get("controllers_config", [])
+ creds = deploy_params.get("credentials_profile", "master_account")
+ image = deploy_params.get("image", "hummingbot/hummingbot:latest")
- hints = {
- "instance_name": "e.g. my_grid_bot",
- "credentials_profile": "e.g. binance_main",
- "max_global_drawdown_quote": "e.g. 1000 (in USDT)",
- "max_controller_drawdown_quote": "e.g. 500 (in USDT)",
- "image": "e.g. hummingbot/hummingbot:latest",
- }
+ if not controllers:
+ await query.answer("No controllers selected", show_alert=True)
+ return
- label = labels.get(field_name, field_name)
- hint = hints.get(field_name, "")
+ # Instance name is just the credentials profile - API adds timestamp
+ generated_name = creds
- # Set state for text input
- context.user_data["bots_state"] = f"deploy_set:{field_name}"
- context.user_data["editing_deploy_field"] = field_name
+ # Store for later use
+ context.user_data["deploy_generated_name"] = generated_name
- # Get current value
- deploy_params = context.user_data.get("deploy_params", {})
- current = deploy_params.get(field_name, "")
- current_str = str(current) if current else "Not set"
+ controllers_str = "\n".join([f"• `{escape_markdown_v2(c)}`" for c in controllers])
+ image_short = image.split("/")[-1] if "/" in image else image
- message = (
- f"*Set {escape_markdown_v2(label)}*\n\n"
- f"Current: `{escape_markdown_v2(current_str)}`\n\n"
- )
+ lines = [
+ r"*Confirm Deployment*",
+ "",
+ r"*Controllers:*",
+ controllers_str,
+ "",
+ f"*Account:* `{escape_markdown_v2(creds)}`",
+ f"*Image:* `{escape_markdown_v2(image_short)}`",
+ "",
+ r"*Instance Name:*",
+ f"`{escape_markdown_v2(generated_name)}`",
+ "",
+ r"_Click the name to deploy, or send a custom name_",
+ ]
- if hint:
- message += f"_Hint: {escape_markdown_v2(hint)}_\n\n"
+ keyboard = [
+ [
+ InlineKeyboardButton(f"✅ Deploy as {generated_name[:25]}", callback_data="bots:execute_deploy"),
+ ],
+ [
+ InlineKeyboardButton("« Back", callback_data="bots:deploy_config"),
+ ],
+ ]
- message += r"Type the new value or tap Cancel\."
+ # Set state to allow custom name input
+ context.user_data["bots_state"] = "deploy_custom_name"
- keyboard = [[InlineKeyboardButton("Cancel", callback_data="bots:deploy_form_back")]]
- reply_markup = InlineKeyboardMarkup(keyboard)
+ # Store the generated name in deploy_params
+ deploy_params["instance_name"] = generated_name
+ # Set drawdowns to None (skip them)
+ deploy_params["max_global_drawdown_quote"] = None
+ deploy_params["max_controller_drawdown_quote"] = None
+ context.user_data["deploy_params"] = deploy_params
await query.message.edit_text(
- message,
+ "\n".join(lines),
parse_mode="MarkdownV2",
- reply_markup=reply_markup
+ reply_markup=InlineKeyboardMarkup(keyboard)
)
-async def process_deploy_field_input(update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str) -> None:
- """Process user input for a deploy field"""
- field_name = context.user_data.get("editing_deploy_field")
-
- if not field_name:
- await update.message.reply_text("No field selected. Please try again.")
- return
+async def handle_deploy_custom_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle custom instance name input (called from message handler)"""
+ # This is triggered via message handler when in deploy_custom_name state
+ pass # The actual processing happens in process_deploy_custom_name_input
- deploy_params = context.user_data.get("deploy_params", DEPLOY_DEFAULTS.copy())
+async def process_deploy_custom_name_input(update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str) -> None:
+ """Process custom instance name input and execute deployment"""
try:
- # Parse the value based on field type
- if field_name in ["max_global_drawdown_quote", "max_controller_drawdown_quote"]:
- value = float(user_input) if user_input.strip() else None
- else:
- value = user_input.strip()
-
- # Set the value
- deploy_params[field_name] = value
- context.user_data["deploy_params"] = deploy_params
-
- # Clear field editing state
- context.user_data.pop("editing_deploy_field", None)
- context.user_data["bots_state"] = "deploy_configure"
-
- # Show confirmation
- label = field_name.replace("_", " ").title()
- await update.message.reply_text(f"{label} set to: {value}")
-
- # Show button to return to form
- keyboard = [[InlineKeyboardButton("Continue", callback_data="bots:deploy_form_back")]]
- await update.message.reply_text(
- "Value updated\\. Tap to continue\\.",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
-
- except ValueError as e:
- await update.message.reply_text(f"Invalid value. Please enter a valid number.")
-
+ await update.message.delete()
+ except:
+ pass
-async def handle_execute_deploy(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Execute the deployment of selected controllers"""
- query = update.callback_query
+ custom_name = user_input.strip()
+ if not custom_name:
+ return
deploy_params = context.user_data.get("deploy_params", {})
+ deploy_params["instance_name"] = custom_name
+ context.user_data["deploy_params"] = deploy_params
- instance_name = deploy_params.get("instance_name")
- credentials_profile = deploy_params.get("credentials_profile")
- controllers_config = deploy_params.get("controllers_config", [])
+ # Execute deployment with custom name
+ message_id = context.user_data.get("deploy_message_id")
+ chat_id = context.user_data.get("deploy_chat_id")
- if not instance_name or not credentials_profile:
- await query.answer("Instance name and credentials are required", show_alert=True)
+ if not message_id or not chat_id:
return
- if not controllers_config:
- await query.answer("No controllers selected", show_alert=True)
- return
+ controllers = deploy_params.get("controllers_config", [])
+ creds = deploy_params.get("credentials_profile", "master_account")
+ image = deploy_params.get("image", "hummingbot/hummingbot:latest")
- # Show deploying message FIRST (before the long operation)
- controllers_str = ", ".join([f"`{escape_markdown_v2(c)}`" for c in controllers_config])
- await query.message.edit_text(
- f"*Deploying\\.\\.\\.*\n\n"
- f"*Instance:* `{escape_markdown_v2(instance_name)}`\n"
- f"*Controllers:*\n{controllers_str}\n\n"
- f"Please wait, this may take a moment\\.\\.\\.",
- parse_mode="MarkdownV2"
- )
+ controllers_str = ", ".join([f"`{escape_markdown_v2(c)}`" for c in controllers])
+
+ # Update message to show deploying
+ try:
+ await context.bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text=(
+ f"*Deploying\\.\\.\\.*\n\n"
+ f"*Instance:* `{escape_markdown_v2(custom_name)}`\n"
+ f"*Controllers:* {controllers_str}\n\n"
+ f"Please wait, this may take a moment\\.\\.\\."
+ ),
+ parse_mode="MarkdownV2"
+ )
+ except Exception as e:
+ logger.error(f"Error updating deploy message: {e}")
try:
- client = await get_bots_client()
+ client = await get_bots_client(chat_id)
- # Deploy using deploy_v2_controllers (this can take time)
result = await client.bot_orchestration.deploy_v2_controllers(
- instance_name=instance_name,
- credentials_profile=credentials_profile,
- controllers_config=controllers_config,
- max_global_drawdown_quote=deploy_params.get("max_global_drawdown_quote"),
- max_controller_drawdown_quote=deploy_params.get("max_controller_drawdown_quote"),
- image=deploy_params.get("image", "hummingbot/hummingbot:latest"),
+ instance_name=custom_name,
+ credentials_profile=creds,
+ controllers_config=controllers,
+ max_global_drawdown_quote=None,
+ max_controller_drawdown_quote=None,
+ image=image,
)
# Clear deploy state
context.user_data.pop("selected_controllers", None)
context.user_data.pop("deploy_params", None)
context.user_data.pop("bots_state", None)
+ context.user_data.pop("deploy_generated_name", None)
keyboard = [
[InlineKeyboardButton("View Bots", callback_data="bots:main_menu")],
[InlineKeyboardButton("Deploy More", callback_data="bots:deploy_menu")],
]
- reply_markup = InlineKeyboardMarkup(keyboard)
status = result.get("status", "unknown")
message = result.get("message", "")
-
- # Check for success - either status is "success" or message indicates success
is_success = (
status == "success" or
"successfully" in message.lower() or
@@ -3053,522 +4718,975 @@ async def handle_execute_deploy(update: Update, context: ContextTypes.DEFAULT_TY
)
if is_success:
- await query.message.edit_text(
- f"*Deployment Started\\!*\n\n"
- f"*Instance:* `{escape_markdown_v2(instance_name)}`\n"
- f"*Controllers:*\n{controllers_str}\n\n"
- f"The bot is being deployed\\. Check status in Bots menu\\.",
+ await context.bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text=(
+ f"*Deployment Started\\!*\n\n"
+ f"*Instance:* `{escape_markdown_v2(custom_name)}`\n"
+ f"*Controllers:* {controllers_str}\n\n"
+ f"The bot is being deployed\\. Check status in Bots menu\\."
+ ),
parse_mode="MarkdownV2",
- reply_markup=reply_markup
+ reply_markup=InlineKeyboardMarkup(keyboard)
)
else:
error_msg = message or "Unknown error"
- await query.message.edit_text(
- f"*Deployment Failed*\n\n"
- f"Error: {escape_markdown_v2(error_msg)}",
+ await context.bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text=(
+ f"*Deployment Failed*\n\n"
+ f"Error: {escape_markdown_v2(error_msg)}"
+ ),
parse_mode="MarkdownV2",
- reply_markup=reply_markup
+ reply_markup=InlineKeyboardMarkup(keyboard)
)
except Exception as e:
- logger.error(f"Error deploying controllers: {e}", exc_info=True)
- # Use message edit instead of query.answer (which may have expired)
+ logger.error(f"Error deploying with custom name: {e}", exc_info=True)
keyboard = [
- [InlineKeyboardButton("Try Again", callback_data="bots:execute_deploy")],
- [InlineKeyboardButton("Back", callback_data="bots:deploy_form_back")],
+ [InlineKeyboardButton("Try Again", callback_data="bots:deploy_confirm")],
+ [InlineKeyboardButton("Back", callback_data="bots:deploy_config")],
]
- await query.message.edit_text(
- f"*Deployment Failed*\n\n"
- f"Error: {escape_markdown_v2(str(e)[:200])}",
+ await context.bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text=(
+ f"*Deployment Failed*\n\n"
+ f"Error: {escape_markdown_v2(str(e)[:200])}"
+ ),
parse_mode="MarkdownV2",
reply_markup=InlineKeyboardMarkup(keyboard)
)
# ============================================
-# STREAMLINED DEPLOY FLOW
+# PMM MISTER WIZARD
# ============================================
-# Available docker images
-AVAILABLE_IMAGES = [
- "hummingbot/hummingbot:latest",
- "hummingbot/hummingbot:development",
-]
+from .controllers.pmm_mister import (
+ DEFAULTS as PMM_DEFAULTS,
+ WIZARD_STEPS as PMM_WIZARD_STEPS,
+ validate_config as pmm_validate_config,
+ generate_id as pmm_generate_id,
+ parse_spreads,
+ format_spreads,
+)
-async def _get_available_credentials(client) -> List[str]:
- """Fetch list of available credential profiles from the backend"""
+async def show_new_pmm_mister_form(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Start the progressive PMM Mister wizard - Step 1: Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
try:
- accounts = await client.accounts.list_accounts()
- return accounts if accounts else ["master_account"]
+ client = await get_bots_client(chat_id)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
except Exception as e:
- logger.warning(f"Could not fetch accounts, using default: {e}")
- return ["master_account"]
+ logger.warning(f"Could not fetch existing configs: {e}")
+ config = init_new_controller_config(context, "pmm_mister")
+ context.user_data["bots_state"] = "pmm_wizard"
+ context.user_data["pmm_wizard_step"] = "connector_name"
+ context.user_data["pmm_wizard_message_id"] = query.message.message_id
+ context.user_data["pmm_wizard_chat_id"] = query.message.chat_id
-async def show_deploy_config_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Show streamlined deploy configuration with clickable buttons for name, credentials, and image"""
+ await _show_pmm_wizard_connector_step(update, context)
+
+
+async def _show_pmm_wizard_connector_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """PMM Wizard Step 1: Select Connector"""
query = update.callback_query
+ chat_id = update.effective_chat.id
- selected = context.user_data.get("selected_controllers", set())
- configs = context.user_data.get("controller_configs_list", [])
+ try:
+ client = await get_bots_client(chat_id)
+ cex_connectors = await get_available_cex_connectors(context.user_data, client)
- if not selected:
- await query.answer("No controllers selected", show_alert=True)
- return
+ if not cex_connectors:
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ await query.message.edit_text(
+ r"*PMM Mister \- New Config*" + "\n\n"
+ r"No CEX connectors configured\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ return
- # Get selected config names
- controller_names = [
- configs[i].get("id", configs[i].get("config_name", f"config_{i}"))
- for i in selected if i < len(configs)
- ]
+ keyboard = []
+ row = []
+ for connector in cex_connectors:
+ row.append(InlineKeyboardButton(f"🏦 {connector}", callback_data=f"bots:pmm_connector:{connector}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
- # Initialize or get deploy params
- deploy_params = context.user_data.get("deploy_params", {})
- if not deploy_params.get("controllers_config"):
- creds_default = "master_account"
- deploy_params = {
- "controllers_config": controller_names,
- "credentials_profile": creds_default,
- "image": "hummingbot/hummingbot:latest",
- "instance_name": creds_default, # Default name = credentials profile
- }
- context.user_data["deploy_params"] = deploy_params
- context.user_data["deploy_message_id"] = query.message.message_id
- context.user_data["deploy_chat_id"] = query.message.chat_id
+ await query.message.edit_text(
+ r"*📈 PMM Mister \- New Config*" + "\n\n"
+ r"*Step 1/7:* 🏦 Select Connector",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- # Build message
- creds = deploy_params.get("credentials_profile", "master_account")
- image = deploy_params.get("image", "hummingbot/hummingbot:latest")
- instance_name = deploy_params.get("instance_name", creds)
+ except Exception as e:
+ logger.error(f"Error in PMM connector step: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ await query.message.edit_text(
+ format_error_message(f"Error: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- # Build controllers list in code block for readability
- controllers_block = "\n".join(controller_names)
- image_short = image.split("/")[-1] if "/" in image else image
- lines = [
- r"*🚀 Deploy Controllers*",
- "",
- "```",
- controllers_block,
- "```",
- "",
- f"*Name:* `{escape_markdown_v2(instance_name)}`",
- f"*Account:* `{escape_markdown_v2(creds)}`",
- f"*Image:* `{escape_markdown_v2(image_short)}`",
- "",
- r"_Tap buttons below to change settings_",
+async def handle_pmm_wizard_connector(update: Update, context: ContextTypes.DEFAULT_TYPE, connector: str) -> None:
+ """Handle connector selection"""
+ config = get_controller_config(context)
+ config["connector_name"] = connector
+ set_controller_config(context, config)
+ context.user_data["pmm_wizard_step"] = "trading_pair"
+ await _show_pmm_wizard_pair_step(update, context)
+
+
+async def _show_pmm_wizard_pair_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """PMM Wizard Step 2: Trading Pair"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ context.user_data["bots_state"] = "pmm_wizard_input"
+ context.user_data["pmm_wizard_step"] = "trading_pair"
+
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ recent_pairs = []
+ seen = set()
+ for cfg in reversed(existing_configs):
+ pair = cfg.get("trading_pair", "")
+ if pair and pair not in seen:
+ seen.add(pair)
+ recent_pairs.append(pair)
+ if len(recent_pairs) >= 6:
+ break
+
+ keyboard = []
+ if recent_pairs:
+ row = []
+ for pair in recent_pairs:
+ row.append(InlineKeyboardButton(pair, callback_data=f"bots:pmm_pair:{pair}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ await query.message.edit_text(
+ r"*📈 PMM Mister \- New Config*" + "\n\n"
+ f"*Connector:* `{escape_markdown_v2(connector)}`" + "\n\n"
+ r"*Step 2/7:* 🔗 Trading Pair" + "\n\n"
+ r"Select or type a pair:",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
+
+async def handle_pmm_wizard_pair(update: Update, context: ContextTypes.DEFAULT_TYPE, pair: str) -> None:
+ """Handle pair selection"""
+ config = get_controller_config(context)
+ config["trading_pair"] = pair.upper()
+ set_controller_config(context, config)
+
+ connector = config.get("connector_name", "")
+
+ # Only ask for leverage on perpetual exchanges
+ if connector.endswith("_perpetual"):
+ context.user_data["pmm_wizard_step"] = "leverage"
+ await _show_pmm_wizard_leverage_step(update, context)
+ else:
+ # Spot exchange - set leverage to 1 and skip to allocation
+ config["leverage"] = 1
+ set_controller_config(context, config)
+ context.user_data["pmm_wizard_step"] = "portfolio_allocation"
+ await _show_pmm_wizard_allocation_step(update, context)
+
+
+async def _show_pmm_wizard_leverage_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """PMM Wizard Step 3: Leverage"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:pmm_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:pmm_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:pmm_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:pmm_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:pmm_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:pmm_leverage:75"),
+ ],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
]
- # Build keyboard - one button per row for better readability
+ await query.message.edit_text(
+ r"*📈 PMM Mister \- New Config*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"*Step 3/7:* ⚡ Leverage",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
+
+async def handle_pmm_wizard_leverage(update: Update, context: ContextTypes.DEFAULT_TYPE, leverage: int) -> None:
+ """Handle leverage selection"""
+ config = get_controller_config(context)
+ config["leverage"] = leverage
+ set_controller_config(context, config)
+ context.user_data["pmm_wizard_step"] = "portfolio_allocation"
+ await _show_pmm_wizard_allocation_step(update, context)
+
+
+async def _show_pmm_wizard_allocation_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """PMM Wizard Step 4: Portfolio Allocation"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 20)
+
keyboard = [
- [InlineKeyboardButton(f"📝 Name: {instance_name[:25]}", callback_data="bots:select_name:_show")],
- [InlineKeyboardButton(f"👤 Account: {creds}", callback_data="bots:select_creds:_show")],
- [InlineKeyboardButton(f"🐳 Image: {image_short}", callback_data="bots:select_image:_show")],
- [InlineKeyboardButton("✅ Deploy Now", callback_data="bots:execute_deploy")],
- [InlineKeyboardButton("« Back", callback_data="bots:deploy_menu")],
+ [
+ InlineKeyboardButton("1%", callback_data="bots:pmm_alloc:0.01"),
+ InlineKeyboardButton("2%", callback_data="bots:pmm_alloc:0.02"),
+ InlineKeyboardButton("5%", callback_data="bots:pmm_alloc:0.05"),
+ ],
+ [
+ InlineKeyboardButton("10%", callback_data="bots:pmm_alloc:0.1"),
+ InlineKeyboardButton("20%", callback_data="bots:pmm_alloc:0.2"),
+ InlineKeyboardButton("50%", callback_data="bots:pmm_alloc:0.5"),
+ ],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
]
- reply_markup = InlineKeyboardMarkup(keyboard)
+ await query.message.edit_text(
+ r"*📈 PMM Mister \- New Config*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n"
+ f"⚡ `{leverage}x`" + "\n\n"
+ r"*Step 4/7:* 💰 Portfolio Allocation",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
+
+async def handle_pmm_wizard_allocation(update: Update, context: ContextTypes.DEFAULT_TYPE, allocation: float) -> None:
+ """Handle allocation selection"""
+ config = get_controller_config(context)
+ config["portfolio_allocation"] = allocation
+ set_controller_config(context, config)
+ context.user_data["pmm_wizard_step"] = "spreads"
+ await _show_pmm_wizard_spreads_step(update, context)
+
+
+async def _show_pmm_wizard_spreads_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """PMM Wizard Step 5: Spreads"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 20)
+ allocation = config.get("portfolio_allocation", 0.05)
- # Set drawdowns to None (skip them)
- deploy_params["max_global_drawdown_quote"] = None
- deploy_params["max_controller_drawdown_quote"] = None
- context.user_data["deploy_params"] = deploy_params
+ context.user_data["bots_state"] = "pmm_wizard_input"
+ context.user_data["pmm_wizard_step"] = "spreads"
+
+ keyboard = [
+ [InlineKeyboardButton("Tight: 0.02%, 0.1%", callback_data="bots:pmm_spreads:0.0002,0.001")],
+ [InlineKeyboardButton("Normal: 0.5%, 1%", callback_data="bots:pmm_spreads:0.005,0.01")],
+ [InlineKeyboardButton("Wide: 1%, 2%", callback_data="bots:pmm_spreads:0.01,0.02")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
await query.message.edit_text(
- "\n".join(lines),
+ r"*📈 PMM Mister \- New Config*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n"
+ f"⚡ `{leverage}x` \\| 💰 `{allocation*100:.0f}%`" + "\n\n"
+ r"*Step 5/7:* 📊 Spreads" + "\n\n"
+ r"_Or type custom: `0\.01,0\.02`_",
parse_mode="MarkdownV2",
- reply_markup=reply_markup
+ reply_markup=InlineKeyboardMarkup(keyboard)
)
-async def handle_select_credentials(update: Update, context: ContextTypes.DEFAULT_TYPE, creds: str) -> None:
- """Handle credentials profile selection"""
- query = update.callback_query
-
- if creds == "_show":
- # Show available credentials profiles
- try:
- client = await get_bots_client()
- available_creds = await _get_available_credentials(client)
- except Exception:
- available_creds = ["master_account"]
+async def handle_pmm_wizard_spreads(update: Update, context: ContextTypes.DEFAULT_TYPE, spreads: str) -> None:
+ """Handle spreads selection"""
+ config = get_controller_config(context)
+ config["buy_spreads"] = spreads
+ config["sell_spreads"] = spreads
+ set_controller_config(context, config)
+ context.user_data["pmm_wizard_step"] = "take_profit"
+ await _show_pmm_wizard_tp_step(update, context)
- deploy_params = context.user_data.get("deploy_params", {})
- current = deploy_params.get("credentials_profile", "master_account")
- lines = [
- r"*Select Credentials Profile*",
- "",
- f"Current: `{escape_markdown_v2(current)}`",
- "",
- r"_Choose an account to deploy with:_",
- ]
+async def _show_pmm_wizard_tp_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """PMM Wizard Step 6: Take Profit"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 20)
+ allocation = config.get("portfolio_allocation", 0.05)
+ spreads = config.get("buy_spreads", "0.0002,0.001")
- # Build buttons for each credential profile
- keyboard = []
- for acc in available_creds:
- marker = "✓ " if acc == current else ""
- keyboard.append([
- InlineKeyboardButton(f"{marker}{acc}", callback_data=f"bots:select_creds:{acc}")
- ])
+ keyboard = [
+ [
+ InlineKeyboardButton("0.01%", callback_data="bots:pmm_tp:0.0001"),
+ InlineKeyboardButton("0.02%", callback_data="bots:pmm_tp:0.0002"),
+ InlineKeyboardButton("0.05%", callback_data="bots:pmm_tp:0.0005"),
+ ],
+ [
+ InlineKeyboardButton("0.1%", callback_data="bots:pmm_tp:0.001"),
+ InlineKeyboardButton("0.2%", callback_data="bots:pmm_tp:0.002"),
+ InlineKeyboardButton("0.5%", callback_data="bots:pmm_tp:0.005"),
+ ],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
- keyboard.append([
- InlineKeyboardButton("« Back", callback_data="bots:deploy_config"),
- ])
+ await query.message.edit_text(
+ r"*📈 PMM Mister \- New Config*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n"
+ f"⚡ `{leverage}x` \\| 💰 `{allocation*100:.0f}%`" + "\n"
+ f"📊 Spreads: `{escape_markdown_v2(spreads)}`" + "\n\n"
+ r"*Step 6/7:* 🎯 Take Profit",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- await query.message.edit_text(
- "\n".join(lines),
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- else:
- # Set the selected credential profile
- deploy_params = context.user_data.get("deploy_params", {})
- deploy_params["credentials_profile"] = creds
- context.user_data["deploy_params"] = deploy_params
- await query.answer(f"Account set to {creds}")
- await show_deploy_config_step(update, context)
+async def handle_pmm_wizard_tp(update: Update, context: ContextTypes.DEFAULT_TYPE, tp: float) -> None:
+ """Handle take profit selection"""
+ config = get_controller_config(context)
+ config["take_profit"] = tp
+ set_controller_config(context, config)
+ context.user_data["pmm_wizard_step"] = "review"
+ await _show_pmm_wizard_review_step(update, context)
-async def handle_select_image(update: Update, context: ContextTypes.DEFAULT_TYPE, image: str) -> None:
- """Handle docker image selection"""
+async def _show_pmm_wizard_review_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """PMM Wizard Step 7: Review with copyable config format"""
query = update.callback_query
+ config = get_controller_config(context)
- if image == "_show":
- # Show available images
- deploy_params = context.user_data.get("deploy_params", {})
- current = deploy_params.get("image", "hummingbot/hummingbot:latest")
+ # Generate ID if not set
+ if not config.get("id"):
+ existing = context.user_data.get("controller_configs_list", [])
+ config["id"] = pmm_generate_id(config, existing)
+ set_controller_config(context, config)
- lines = [
- r"*Select Docker Image*",
- "",
- f"Current: `{escape_markdown_v2(current)}`",
- "",
- r"_Choose an image to deploy with:_",
- ]
+ context.user_data["bots_state"] = "pmm_wizard_input"
+ context.user_data["pmm_wizard_step"] = "review"
- # Build buttons for each image
- keyboard = []
- for img in AVAILABLE_IMAGES:
- marker = "✓ " if img == current else ""
- img_short = img.split("/")[-1] if "/" in img else img
- keyboard.append([
- InlineKeyboardButton(f"{marker}{img_short}", callback_data=f"bots:select_image:{img}")
- ])
+ # Build copyable config block
+ config_block = (
+ f"id: {config.get('id', '')}\n"
+ f"connector_name: {config.get('connector_name', '')}\n"
+ f"trading_pair: {config.get('trading_pair', '')}\n"
+ f"leverage: {config.get('leverage', 1)}\n"
+ f"portfolio_allocation: {config.get('portfolio_allocation', 0.05)}\n"
+ f"buy_spreads: {config.get('buy_spreads', '0.0002,0.001')}\n"
+ f"sell_spreads: {config.get('sell_spreads', '0.0002,0.001')}\n"
+ f"take_profit: {config.get('take_profit', 0.0001)}\n"
+ f"target_base_pct: {config.get('target_base_pct', 0.2)}\n"
+ f"min_base_pct: {config.get('min_base_pct', 0.1)}\n"
+ f"max_base_pct: {config.get('max_base_pct', 0.4)}\n"
+ f"executor_refresh_time: {config.get('executor_refresh_time', 30)}\n"
+ f"buy_cooldown_time: {config.get('buy_cooldown_time', 15)}\n"
+ f"sell_cooldown_time: {config.get('sell_cooldown_time', 15)}\n"
+ f"max_active_executors_by_level: {config.get('max_active_executors_by_level', 4)}"
+ )
- keyboard.append([
- InlineKeyboardButton("« Back", callback_data="bots:deploy_config"),
- ])
+ pair = config.get('trading_pair', '')
+ message_text = (
+ f"*{escape_markdown_v2(pair)}* \\- Review Config\n\n"
+ f"```\n{config_block}\n```\n\n"
+ f"_To edit, send `field: value` lines:_\n"
+ f"`leverage: 20`\n"
+ f"`take_profit: 0.001`"
+ )
- await query.message.edit_text(
- "\n".join(lines),
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- else:
- # Set the selected image
- deploy_params = context.user_data.get("deploy_params", {})
- deploy_params["image"] = image
- context.user_data["deploy_params"] = deploy_params
+ keyboard = [
+ [InlineKeyboardButton("✅ Save Config", callback_data="bots:pmm_save")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
- img_short = image.split("/")[-1] if "/" in image else image
- await query.answer(f"Image set to {img_short}")
- await show_deploy_config_step(update, context)
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
-async def handle_select_instance_name(update: Update, context: ContextTypes.DEFAULT_TYPE, name: str) -> None:
- """Handle instance name selection/editing"""
+async def handle_pmm_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Save PMM config"""
query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
- if name == "_show":
- # Show name editing prompt
- deploy_params = context.user_data.get("deploy_params", {})
- creds = deploy_params.get("credentials_profile", "master_account")
- current = deploy_params.get("instance_name", creds)
+ is_valid, error = pmm_validate_config(config)
+ if not is_valid:
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:pmm_review_back")]]
+ await query.message.edit_text(
+ f"*Validation Error*\n\n{escape_markdown_v2(error or 'Unknown error')}",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ return
- lines = [
- r"*Edit Instance Name*",
- "",
- f"Current: `{escape_markdown_v2(current)}`",
- "",
- r"_Send a new name or choose an option:_",
- ]
+ try:
+ client = await get_bots_client(chat_id)
+ config_id = config.get("id", "")
+ result = await client.controllers.create_or_update_controller_config(config_id, config)
- keyboard = [
- [InlineKeyboardButton(f"✓ Use: {creds}", callback_data=f"bots:select_name:{creds}")],
- [InlineKeyboardButton("« Back", callback_data="bots:deploy_config")],
- ]
+ if result.get("status") == "success" or "success" in str(result).lower():
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_pmm_mister")],
+ [InlineKeyboardButton("Deploy Now", callback_data="bots:deploy_menu")],
+ [InlineKeyboardButton("Back to Menu", callback_data="bots:controller_configs")],
+ ]
+ await query.message.edit_text(
+ r"*✅ Config Saved\!*" + "\n\n"
+ f"*ID:* `{escape_markdown_v2(config.get('id', ''))}`",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ clear_bots_state(context)
+ else:
+ error_msg = result.get("message", str(result))
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:pmm_review_back")]]
+ await query.message.edit_text(
+ f"*Save Failed*\n\n{escape_markdown_v2(error_msg[:200])}",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ except Exception as e:
+ logger.error(f"Error saving PMM config: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:pmm_review_back")]]
await query.message.edit_text(
- "\n".join(lines),
+ f"*Error*\n\n{escape_markdown_v2(str(e)[:200])}",
parse_mode="MarkdownV2",
reply_markup=InlineKeyboardMarkup(keyboard)
)
- # Set state to allow custom name input
- context.user_data["bots_state"] = "deploy_edit_name"
- else:
- # Set the selected name
- deploy_params = context.user_data.get("deploy_params", {})
- deploy_params["instance_name"] = name
- context.user_data["deploy_params"] = deploy_params
- context.user_data["bots_state"] = None
-
- await query.answer(f"Name set to {name[:25]}")
- await show_deploy_config_step(update, context)
+async def handle_pmm_review_back(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Back to review"""
+ await _show_pmm_wizard_review_step(update, context)
-async def process_instance_name_input(update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str) -> None:
- """Process custom instance name input from user text message"""
- try:
- await update.message.delete()
- except:
- pass
- custom_name = user_input.strip()
- if not custom_name:
- return
+async def handle_pmm_edit_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Edit config ID"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ context.user_data["bots_state"] = "pmm_wizard_input"
+ context.user_data["pmm_wizard_step"] = "edit_id"
- # Set the custom name
- deploy_params = context.user_data.get("deploy_params", {})
- deploy_params["instance_name"] = custom_name
- context.user_data["deploy_params"] = deploy_params
- context.user_data["bots_state"] = None
+ keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:pmm_review_back")]]
+ await query.message.edit_text(
+ r"*Edit Config ID*" + "\n\n"
+ f"Current: `{escape_markdown_v2(config.get('id', ''))}`" + "\n\n"
+ r"Enter new ID:",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- # Update the config step message
- message_id = context.user_data.get("deploy_message_id")
- chat_id = context.user_data.get("deploy_chat_id")
- if message_id and chat_id:
- # Create a fake update/query to reuse show_deploy_config_step logic
- # We need to update the existing message, so we'll do it manually
- creds = deploy_params.get("credentials_profile", "master_account")
- image = deploy_params.get("image", "hummingbot/hummingbot:latest")
- controllers = deploy_params.get("controllers_config", [])
+async def handle_pmm_edit_field(update: Update, context: ContextTypes.DEFAULT_TYPE, field: str) -> None:
+ """Handle editing a specific field from review"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ context.user_data["bots_state"] = "pmm_wizard_input"
+ context.user_data["pmm_wizard_step"] = f"edit_{field}"
- controllers_block = "\n".join(controllers)
- image_short = image.split("/")[-1] if "/" in image else image
+ keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:pmm_review_back")]]
- lines = [
- r"*🚀 Deploy Controllers*",
- "",
- "```",
- controllers_block,
- "```",
- "",
- f"*Name:* `{escape_markdown_v2(custom_name)}`",
- f"*Account:* `{escape_markdown_v2(creds)}`",
- f"*Image:* `{escape_markdown_v2(image_short)}`",
- "",
- r"_Tap buttons below to change settings_",
+ if field == "leverage":
+ # Show leverage buttons instead of text input
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:pmm_set:leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:pmm_set:leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:pmm_set:leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:pmm_set:leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:pmm_set:leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:pmm_set:leverage:75"),
+ ],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:pmm_review_back")],
]
+ await query.message.edit_text(
+ r"*Edit Leverage*" + "\n\n"
+ f"Current: `{config.get('leverage', 20)}x`",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ elif field == "allocation":
keyboard = [
- [InlineKeyboardButton(f"📝 Name: {custom_name[:25]}", callback_data="bots:select_name:_show")],
- [InlineKeyboardButton(f"👤 Account: {creds}", callback_data="bots:select_creds:_show")],
- [InlineKeyboardButton(f"🐳 Image: {image_short}", callback_data="bots:select_image:_show")],
- [InlineKeyboardButton("✅ Deploy Now", callback_data="bots:execute_deploy")],
- [InlineKeyboardButton("« Back", callback_data="bots:deploy_menu")],
+ [
+ InlineKeyboardButton("1%", callback_data="bots:pmm_set:allocation:0.01"),
+ InlineKeyboardButton("2%", callback_data="bots:pmm_set:allocation:0.02"),
+ InlineKeyboardButton("5%", callback_data="bots:pmm_set:allocation:0.05"),
+ ],
+ [
+ InlineKeyboardButton("10%", callback_data="bots:pmm_set:allocation:0.1"),
+ InlineKeyboardButton("20%", callback_data="bots:pmm_set:allocation:0.2"),
+ InlineKeyboardButton("50%", callback_data="bots:pmm_set:allocation:0.5"),
+ ],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:pmm_review_back")],
]
+ await query.message.edit_text(
+ r"*Edit Portfolio Allocation*" + "\n\n"
+ f"Current: `{config.get('portfolio_allocation', 0.05)*100:.0f}%`" + "\n\n"
+ r"_Or type a custom value \(e\.g\. 0\.15 for 15%\)_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- try:
- await update.get_bot().edit_message_text(
- chat_id=chat_id,
- message_id=message_id,
- text="\n".join(lines),
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
- except Exception as e:
- logger.error(f"Error updating deploy config message: {e}")
+ elif field == "spreads":
+ keyboard = [
+ [InlineKeyboardButton("Tight: 0.02%, 0.1%", callback_data="bots:pmm_set:spreads:0.0002,0.001")],
+ [InlineKeyboardButton("Normal: 0.5%, 1%", callback_data="bots:pmm_set:spreads:0.005,0.01")],
+ [InlineKeyboardButton("Wide: 1%, 2%", callback_data="bots:pmm_set:spreads:0.01,0.02")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:pmm_review_back")],
+ ]
+ await query.message.edit_text(
+ r"*Edit Spreads*" + "\n\n"
+ f"Buy: `{escape_markdown_v2(config.get('buy_spreads', ''))}`" + "\n"
+ f"Sell: `{escape_markdown_v2(config.get('sell_spreads', ''))}`" + "\n\n"
+ r"_Or type custom spreads \(e\.g\. 0\.001,0\.002\)_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ elif field == "take_profit":
+ keyboard = [
+ [
+ InlineKeyboardButton("0.01%", callback_data="bots:pmm_set:take_profit:0.0001"),
+ InlineKeyboardButton("0.02%", callback_data="bots:pmm_set:take_profit:0.0002"),
+ InlineKeyboardButton("0.05%", callback_data="bots:pmm_set:take_profit:0.0005"),
+ ],
+ [
+ InlineKeyboardButton("0.1%", callback_data="bots:pmm_set:take_profit:0.001"),
+ InlineKeyboardButton("0.2%", callback_data="bots:pmm_set:take_profit:0.002"),
+ InlineKeyboardButton("0.5%", callback_data="bots:pmm_set:take_profit:0.005"),
+ ],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:pmm_review_back")],
+ ]
+ await query.message.edit_text(
+ r"*Edit Take Profit*" + "\n\n"
+ f"Current: `{config.get('take_profit', 0.0001)*100:.2f}%`" + "\n\n"
+ r"_Or type a custom value \(e\.g\. 0\.001 for 0\.1%\)_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
-async def handle_deploy_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Show deployment confirmation with auto-generated instance name"""
- query = update.callback_query
+ elif field == "base":
+ await query.message.edit_text(
+ r"*Edit Base Percentages*" + "\n\n"
+ f"Min: `{config.get('min_base_pct', 0.1)*100:.0f}%`" + "\n"
+ f"Target: `{config.get('target_base_pct', 0.2)*100:.0f}%`" + "\n"
+ f"Max: `{config.get('max_base_pct', 0.4)*100:.0f}%`" + "\n\n"
+ r"Enter new values \(min,target,max\):" + "\n"
+ r"_Example: 0\.1,0\.2,0\.4_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- deploy_params = context.user_data.get("deploy_params", {})
- controllers = deploy_params.get("controllers_config", [])
- creds = deploy_params.get("credentials_profile", "master_account")
- image = deploy_params.get("image", "hummingbot/hummingbot:latest")
- if not controllers:
- await query.answer("No controllers selected", show_alert=True)
- return
+async def handle_pmm_set_field(update: Update, context: ContextTypes.DEFAULT_TYPE, field: str, value: str) -> None:
+ """Handle setting a field value from button click"""
+ config = get_controller_config(context)
- # Instance name is just the credentials profile - API adds timestamp
- generated_name = creds
+ if field == "leverage":
+ config["leverage"] = int(value)
+ elif field == "allocation":
+ config["portfolio_allocation"] = float(value)
+ elif field == "spreads":
+ config["buy_spreads"] = value
+ config["sell_spreads"] = value
+ elif field == "take_profit":
+ config["take_profit"] = float(value)
- # Store for later use
- context.user_data["deploy_generated_name"] = generated_name
+ set_controller_config(context, config)
+ await _show_pmm_wizard_review_step(update, context)
- controllers_str = "\n".join([f"• `{escape_markdown_v2(c)}`" for c in controllers])
- image_short = image.split("/")[-1] if "/" in image else image
- lines = [
- r"*Confirm Deployment*",
- "",
- r"*Controllers:*",
- controllers_str,
- "",
- f"*Account:* `{escape_markdown_v2(creds)}`",
- f"*Image:* `{escape_markdown_v2(image_short)}`",
- "",
- r"*Instance Name:*",
- f"`{escape_markdown_v2(generated_name)}`",
- "",
- r"_Click the name to deploy, or send a custom name_",
- ]
+async def handle_pmm_edit_advanced(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show advanced settings"""
+ query = update.callback_query
+ config = get_controller_config(context)
keyboard = [
[
- InlineKeyboardButton(f"✅ Deploy as {generated_name[:25]}", callback_data="bots:execute_deploy"),
+ InlineKeyboardButton("Base %", callback_data="bots:pmm_adv:base"),
+ InlineKeyboardButton("Cooldowns", callback_data="bots:pmm_adv:cooldown"),
],
[
- InlineKeyboardButton("« Back", callback_data="bots:deploy_config"),
+ InlineKeyboardButton("Refresh Time", callback_data="bots:pmm_adv:refresh"),
+ InlineKeyboardButton("Max Executors", callback_data="bots:pmm_adv:max_exec"),
],
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:pmm_review_back")],
]
- # Set state to allow custom name input
- context.user_data["bots_state"] = "deploy_custom_name"
-
- # Store the generated name in deploy_params
- deploy_params["instance_name"] = generated_name
- # Set drawdowns to None (skip them)
- deploy_params["max_global_drawdown_quote"] = None
- deploy_params["max_controller_drawdown_quote"] = None
- context.user_data["deploy_params"] = deploy_params
-
await query.message.edit_text(
- "\n".join(lines),
+ r"*Advanced Settings*" + "\n\n"
+ f"📈 *Base %:* min=`{config.get('min_base_pct', 0.1)*100:.0f}%` "
+ f"target=`{config.get('target_base_pct', 0.2)*100:.0f}%` "
+ f"max=`{config.get('max_base_pct', 0.4)*100:.0f}%`" + "\n"
+ f"⏱️ *Refresh:* `{config.get('executor_refresh_time', 30)}s`" + "\n"
+ f"⏸️ *Cooldowns:* buy=`{config.get('buy_cooldown_time', 15)}s` "
+ f"sell=`{config.get('sell_cooldown_time', 15)}s`" + "\n"
+ f"🔢 *Max Executors:* `{config.get('max_active_executors_by_level', 4)}`",
parse_mode="MarkdownV2",
reply_markup=InlineKeyboardMarkup(keyboard)
)
-async def handle_deploy_custom_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle custom instance name input (called from message handler)"""
- # This is triggered via message handler when in deploy_custom_name state
- pass # The actual processing happens in process_deploy_custom_name_input
-
-
-async def process_deploy_custom_name_input(update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str) -> None:
- """Process custom instance name input and execute deployment"""
- try:
- await update.message.delete()
- except:
- pass
-
- custom_name = user_input.strip()
- if not custom_name:
- return
-
- deploy_params = context.user_data.get("deploy_params", {})
- deploy_params["instance_name"] = custom_name
- context.user_data["deploy_params"] = deploy_params
+async def handle_pmm_adv_setting(update: Update, context: ContextTypes.DEFAULT_TYPE, setting: str) -> None:
+ """Handle advanced setting edit"""
+ query = update.callback_query
+ config = get_controller_config(context)
- # Execute deployment with custom name
- message_id = context.user_data.get("deploy_message_id")
- chat_id = context.user_data.get("deploy_chat_id")
+ context.user_data["bots_state"] = "pmm_wizard_input"
+ context.user_data["pmm_wizard_step"] = f"adv_{setting}"
- if not message_id or not chat_id:
- return
+ hints = {
+ "base": ("Base Percentages", f"min={config.get('min_base_pct', 0.1)}, target={config.get('target_base_pct', 0.2)}, max={config.get('max_base_pct', 0.4)}", "min,target,max as decimals"),
+ "cooldown": ("Cooldown Times", f"buy={config.get('buy_cooldown_time', 15)}s, sell={config.get('sell_cooldown_time', 15)}s", "buy,sell in seconds"),
+ "refresh": ("Refresh Time", f"{config.get('executor_refresh_time', 30)}s", "seconds"),
+ "max_exec": ("Max Executors", str(config.get("max_active_executors_by_level", 4)), "number"),
+ }
+ label, current, hint = hints.get(setting, (setting, "", ""))
- controllers = deploy_params.get("controllers_config", [])
- creds = deploy_params.get("credentials_profile", "master_account")
- image = deploy_params.get("image", "hummingbot/hummingbot:latest")
+ keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:pmm_edit_advanced")]]
+ await query.message.edit_text(
+ f"*Edit {escape_markdown_v2(label)}*" + "\n\n"
+ f"Current: `{escape_markdown_v2(current)}`" + "\n\n"
+ f"Enter new value \\({escape_markdown_v2(hint)}\\):",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
- controllers_str = ", ".join([f"`{escape_markdown_v2(c)}`" for c in controllers])
- # Update message to show deploying
- try:
- await context.bot.edit_message_text(
- chat_id=chat_id,
- message_id=message_id,
- text=(
- f"*Deploying\\.\\.\\.*\n\n"
- f"*Instance:* `{escape_markdown_v2(custom_name)}`\n"
- f"*Controllers:* {controllers_str}\n\n"
- f"Please wait, this may take a moment\\.\\.\\."
- ),
- parse_mode="MarkdownV2"
- )
- except Exception as e:
- logger.error(f"Error updating deploy message: {e}")
+async def process_pmm_wizard_input(update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str) -> None:
+ """Process text input during PMM wizard"""
+ step = context.user_data.get("pmm_wizard_step", "")
+ config = get_controller_config(context)
+ message_id = context.user_data.get("pmm_wizard_message_id")
+ chat_id = context.user_data.get("pmm_wizard_chat_id")
try:
- client = await get_bots_client()
-
- result = await client.bot_orchestration.deploy_v2_controllers(
- instance_name=custom_name,
- credentials_profile=creds,
- controllers_config=controllers,
- max_global_drawdown_quote=None,
- max_controller_drawdown_quote=None,
- image=image,
- )
-
- # Clear deploy state
- context.user_data.pop("selected_controllers", None)
- context.user_data.pop("deploy_params", None)
- context.user_data.pop("bots_state", None)
- context.user_data.pop("deploy_generated_name", None)
-
- keyboard = [
- [InlineKeyboardButton("View Bots", callback_data="bots:main_menu")],
- [InlineKeyboardButton("Deploy More", callback_data="bots:deploy_menu")],
- ]
-
- status = result.get("status", "unknown")
- message = result.get("message", "")
- is_success = (
- status == "success" or
- "successfully" in message.lower() or
- "created" in message.lower()
- )
+ await update.message.delete()
+ except Exception:
+ pass
- if is_success:
+ if step == "trading_pair":
+ config["trading_pair"] = user_input.upper()
+ set_controller_config(context, config)
+ connector = config.get("connector_name", "")
+
+ # Only ask for leverage on perpetual exchanges
+ if connector.endswith("_perpetual"):
+ context.user_data["pmm_wizard_step"] = "leverage"
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:pmm_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:pmm_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:pmm_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:pmm_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:pmm_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:pmm_leverage:75"),
+ ],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
await context.bot.edit_message_text(
- chat_id=chat_id,
- message_id=message_id,
- text=(
- f"*Deployment Started\\!*\n\n"
- f"*Instance:* `{escape_markdown_v2(custom_name)}`\n"
- f"*Controllers:* {controllers_str}\n\n"
- f"The bot is being deployed\\. Check status in Bots menu\\."
- ),
+ chat_id=chat_id, message_id=message_id,
+ text=r"*📈 PMM Mister \- New Config*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(config['trading_pair'])}`" + "\n\n"
+ r"*Step 3/7:* ⚡ Leverage",
parse_mode="MarkdownV2",
reply_markup=InlineKeyboardMarkup(keyboard)
)
else:
- error_msg = message or "Unknown error"
+ # Spot exchange - set leverage to 1 and skip to allocation
+ config["leverage"] = 1
+ set_controller_config(context, config)
+ context.user_data["pmm_wizard_step"] = "portfolio_allocation"
+ keyboard = [
+ [
+ InlineKeyboardButton("1%", callback_data="bots:pmm_alloc:0.01"),
+ InlineKeyboardButton("2%", callback_data="bots:pmm_alloc:0.02"),
+ InlineKeyboardButton("5%", callback_data="bots:pmm_alloc:0.05"),
+ ],
+ [
+ InlineKeyboardButton("10%", callback_data="bots:pmm_alloc:0.1"),
+ InlineKeyboardButton("20%", callback_data="bots:pmm_alloc:0.2"),
+ InlineKeyboardButton("50%", callback_data="bots:pmm_alloc:0.5"),
+ ],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
await context.bot.edit_message_text(
- chat_id=chat_id,
- message_id=message_id,
- text=(
- f"*Deployment Failed*\n\n"
- f"Error: {escape_markdown_v2(error_msg)}"
- ),
+ chat_id=chat_id, message_id=message_id,
+ text=r"*📈 PMM Mister \- New Config*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(config['trading_pair'])}`" + "\n\n"
+ r"*Step 4/7:* 💰 Portfolio Allocation",
parse_mode="MarkdownV2",
reply_markup=InlineKeyboardMarkup(keyboard)
)
- except Exception as e:
- logger.error(f"Error deploying with custom name: {e}", exc_info=True)
+ elif step == "spreads":
+ config["buy_spreads"] = user_input.strip()
+ config["sell_spreads"] = user_input.strip()
+ set_controller_config(context, config)
+ context.user_data["pmm_wizard_step"] = "take_profit"
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 20)
+ allocation = config.get("portfolio_allocation", 0.05)
keyboard = [
- [InlineKeyboardButton("Try Again", callback_data="bots:deploy_confirm")],
- [InlineKeyboardButton("Back", callback_data="bots:deploy_config")],
+ [
+ InlineKeyboardButton("0.01%", callback_data="bots:pmm_tp:0.0001"),
+ InlineKeyboardButton("0.02%", callback_data="bots:pmm_tp:0.0002"),
+ InlineKeyboardButton("0.05%", callback_data="bots:pmm_tp:0.0005"),
+ ],
+ [
+ InlineKeyboardButton("0.1%", callback_data="bots:pmm_tp:0.001"),
+ InlineKeyboardButton("0.2%", callback_data="bots:pmm_tp:0.002"),
+ InlineKeyboardButton("0.5%", callback_data="bots:pmm_tp:0.005"),
+ ],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
]
await context.bot.edit_message_text(
- chat_id=chat_id,
- message_id=message_id,
- text=(
- f"*Deployment Failed*\n\n"
- f"Error: {escape_markdown_v2(str(e)[:200])}"
- ),
+ chat_id=chat_id, message_id=message_id,
+ text=r"*📈 PMM Mister \- New Config*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n"
+ f"⚡ `{leverage}x` \\| 💰 `{allocation*100:.0f}%`" + "\n"
+ f"📊 Spreads: `{escape_markdown_v2(user_input.strip())}`" + "\n\n"
+ r"*Step 6/7:* 🎯 Take Profit",
parse_mode="MarkdownV2",
reply_markup=InlineKeyboardMarkup(keyboard)
)
+
+ elif step == "edit_id":
+ config["id"] = user_input.strip()
+ set_controller_config(context, config)
+ await _pmm_show_review(context, chat_id, message_id, config)
+
+ elif step == "edit_allocation":
+ try:
+ val = float(user_input.strip())
+ if val > 1: # User entered percentage
+ val = val / 100
+ config["portfolio_allocation"] = val
+ set_controller_config(context, config)
+ except ValueError:
+ pass
+ await _pmm_show_review(context, chat_id, message_id, config)
+
+ elif step == "edit_spreads":
+ config["buy_spreads"] = user_input.strip()
+ config["sell_spreads"] = user_input.strip()
+ set_controller_config(context, config)
+ await _pmm_show_review(context, chat_id, message_id, config)
+
+ elif step == "edit_take_profit":
+ try:
+ val = float(user_input.strip())
+ config["take_profit"] = val
+ set_controller_config(context, config)
+ except ValueError:
+ pass
+ await _pmm_show_review(context, chat_id, message_id, config)
+
+ elif step == "edit_base":
+ try:
+ parts = [float(x.strip()) for x in user_input.split(",")]
+ if len(parts) == 3:
+ config["min_base_pct"], config["target_base_pct"], config["max_base_pct"] = parts
+ set_controller_config(context, config)
+ except ValueError:
+ pass
+ await _pmm_show_review(context, chat_id, message_id, config)
+
+ elif step == "adv_base":
+ try:
+ parts = [float(x.strip()) for x in user_input.split(",")]
+ if len(parts) == 3:
+ config["min_base_pct"], config["target_base_pct"], config["max_base_pct"] = parts
+ set_controller_config(context, config)
+ except ValueError:
+ pass
+ await _pmm_show_advanced(context, chat_id, message_id, config)
+
+ elif step == "adv_cooldown":
+ try:
+ parts = [int(x.strip()) for x in user_input.split(",")]
+ if len(parts) == 2:
+ config["buy_cooldown_time"], config["sell_cooldown_time"] = parts
+ set_controller_config(context, config)
+ except ValueError:
+ pass
+ await _pmm_show_advanced(context, chat_id, message_id, config)
+
+ elif step == "adv_refresh":
+ try:
+ config["executor_refresh_time"] = int(user_input)
+ set_controller_config(context, config)
+ except ValueError:
+ pass
+ await _pmm_show_advanced(context, chat_id, message_id, config)
+
+ elif step == "adv_max_exec":
+ try:
+ config["max_active_executors_by_level"] = int(user_input)
+ set_controller_config(context, config)
+ except ValueError:
+ pass
+ await _pmm_show_advanced(context, chat_id, message_id, config)
+
+ elif step == "review":
+ # Parse field: value or field=value pairs
+ field_map = {
+ "id": ("id", str),
+ "connector_name": ("connector_name", str),
+ "trading_pair": ("trading_pair", str),
+ "leverage": ("leverage", int),
+ "portfolio_allocation": ("portfolio_allocation", float),
+ "buy_spreads": ("buy_spreads", str),
+ "sell_spreads": ("sell_spreads", str),
+ "take_profit": ("take_profit", float),
+ "target_base_pct": ("target_base_pct", float),
+ "min_base_pct": ("min_base_pct", float),
+ "max_base_pct": ("max_base_pct", float),
+ "executor_refresh_time": ("executor_refresh_time", int),
+ "buy_cooldown_time": ("buy_cooldown_time", int),
+ "sell_cooldown_time": ("sell_cooldown_time", int),
+ "max_active_executors_by_level": ("max_active_executors_by_level", int),
+ }
+
+ updated_fields = []
+ lines = user_input.strip().split("\n")
+
+ for line in lines:
+ line = line.strip()
+ if not line:
+ continue
+
+ # Parse field: value or field=value
+ if ":" in line:
+ parts = line.split(":", 1)
+ elif "=" in line:
+ parts = line.split("=", 1)
+ else:
+ continue
+
+ if len(parts) != 2:
+ continue
+
+ field_name = parts[0].strip().lower()
+ value_str = parts[1].strip()
+
+ if field_name in field_map:
+ config_key, type_fn = field_map[field_name]
+ try:
+ if type_fn == str:
+ config[config_key] = value_str
+ else:
+ config[config_key] = type_fn(value_str)
+ updated_fields.append(field_name)
+ except (ValueError, TypeError):
+ pass
+
+ if updated_fields:
+ set_controller_config(context, config)
+ await _pmm_show_review(context, chat_id, message_id, config)
+
+
+async def _pmm_show_review(context, chat_id, message_id, config):
+ """Helper to show review step with copyable config format"""
+ # Build copyable config block
+ config_block = (
+ f"id: {config.get('id', '')}\n"
+ f"connector_name: {config.get('connector_name', '')}\n"
+ f"trading_pair: {config.get('trading_pair', '')}\n"
+ f"leverage: {config.get('leverage', 1)}\n"
+ f"portfolio_allocation: {config.get('portfolio_allocation', 0.05)}\n"
+ f"buy_spreads: {config.get('buy_spreads', '0.0002,0.001')}\n"
+ f"sell_spreads: {config.get('sell_spreads', '0.0002,0.001')}\n"
+ f"take_profit: {config.get('take_profit', 0.0001)}\n"
+ f"target_base_pct: {config.get('target_base_pct', 0.2)}\n"
+ f"min_base_pct: {config.get('min_base_pct', 0.1)}\n"
+ f"max_base_pct: {config.get('max_base_pct', 0.4)}\n"
+ f"executor_refresh_time: {config.get('executor_refresh_time', 30)}\n"
+ f"buy_cooldown_time: {config.get('buy_cooldown_time', 15)}\n"
+ f"sell_cooldown_time: {config.get('sell_cooldown_time', 15)}\n"
+ f"max_active_executors_by_level: {config.get('max_active_executors_by_level', 4)}"
+ )
+
+ pair = config.get('trading_pair', '')
+ message_text = (
+ f"*{escape_markdown_v2(pair)}* \\- Review Config\n\n"
+ f"```\n{config_block}\n```\n\n"
+ f"_To edit, send `field: value` lines:_\n"
+ f"`leverage: 20`\n"
+ f"`take_profit: 0.001`"
+ )
+
+ keyboard = [
+ [InlineKeyboardButton("✅ Save Config", callback_data="bots:pmm_save")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+
+ await context.bot.edit_message_text(
+ chat_id=chat_id, message_id=message_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
+
+async def _pmm_show_advanced(context, chat_id, message_id, config):
+ """Helper to show advanced settings"""
+ keyboard = [
+ [
+ InlineKeyboardButton("Base %", callback_data="bots:pmm_adv:base"),
+ InlineKeyboardButton("Cooldowns", callback_data="bots:pmm_adv:cooldown"),
+ ],
+ [
+ InlineKeyboardButton("Refresh Time", callback_data="bots:pmm_adv:refresh"),
+ InlineKeyboardButton("Max Executors", callback_data="bots:pmm_adv:max_exec"),
+ ],
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:pmm_review_back")],
+ ]
+ await context.bot.edit_message_text(
+ chat_id=chat_id, message_id=message_id,
+ text=r"*Advanced Settings*" + "\n\n"
+ f"📈 *Base %:* min=`{config.get('min_base_pct', 0.1)*100:.0f}%` "
+ f"target=`{config.get('target_base_pct', 0.2)*100:.0f}%` "
+ f"max=`{config.get('max_base_pct', 0.4)*100:.0f}%`" + "\n"
+ f"⏱️ *Refresh:* `{config.get('executor_refresh_time', 30)}s`" + "\n"
+ f"⏸️ *Cooldowns:* buy=`{config.get('buy_cooldown_time', 15)}s` "
+ f"sell=`{config.get('sell_cooldown_time', 15)}s`" + "\n"
+ f"🔢 *Max Executors:* `{config.get('max_active_executors_by_level', 4)}`",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
diff --git a/handlers/bots/controllers/__init__.py b/handlers/bots/controllers/__init__.py
index c47c88d..46d7d43 100644
--- a/handlers/bots/controllers/__init__.py
+++ b/handlers/bots/controllers/__init__.py
@@ -13,11 +13,13 @@
from ._base import BaseController, ControllerField
from .grid_strike import GridStrikeController
+from .pmm_mister import PmmMisterController
# Registry of controller types
_CONTROLLER_REGISTRY: Dict[str, Type[BaseController]] = {
"grid_strike": GridStrikeController,
+ "pmm_mister": PmmMisterController,
}
@@ -94,6 +96,7 @@ def get_controller_info() -> Dict[str, Dict[str, str]]:
"ControllerField",
# Controller implementations
"GridStrikeController",
+ "PmmMisterController",
# Backwards compatibility
"SUPPORTED_CONTROLLERS",
]
diff --git a/handlers/bots/controllers/grid_strike/__init__.py b/handlers/bots/controllers/grid_strike/__init__.py
index 6f7ac10..fe93a89 100644
--- a/handlers/bots/controllers/grid_strike/__init__.py
+++ b/handlers/bots/controllers/grid_strike/__init__.py
@@ -29,6 +29,13 @@
generate_id,
)
from .chart import generate_chart, generate_preview_chart
+from .grid_analysis import (
+ calculate_natr,
+ calculate_price_stats,
+ suggest_grid_params,
+ generate_theoretical_grid,
+ format_grid_summary,
+)
class GridStrikeController(BaseController):
@@ -103,4 +110,10 @@ def generate_id(
"generate_id",
"generate_chart",
"generate_preview_chart",
+ # Grid analysis
+ "calculate_natr",
+ "calculate_price_stats",
+ "suggest_grid_params",
+ "generate_theoretical_grid",
+ "format_grid_summary",
]
diff --git a/handlers/bots/controllers/grid_strike/chart.py b/handlers/bots/controllers/grid_strike/chart.py
index 31397e0..98b125f 100644
--- a/handlers/bots/controllers/grid_strike/chart.py
+++ b/handlers/bots/controllers/grid_strike/chart.py
@@ -7,32 +7,17 @@
- End price line (entry zone end)
- Limit price line (stop loss)
- Current price line
+
+Uses the unified candlestick chart function from visualizations module.
"""
import io
-from datetime import datetime
from typing import Any, Dict, List, Optional
-import plotly.graph_objects as go
-
+from handlers.dex.visualizations import generate_candlestick_chart, DARK_THEME
from .config import SIDE_LONG
-# Dark theme (consistent with portfolio_graphs.py)
-DARK_THEME = {
- "bgcolor": "#0a0e14",
- "paper_bgcolor": "#0a0e14",
- "plot_bgcolor": "#131720",
- "font_color": "#e6edf3",
- "font_family": "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif",
- "grid_color": "#21262d",
- "axis_color": "#8b949e",
- "up_color": "#10b981", # Green for bullish
- "down_color": "#ef4444", # Red for bearish
- "line_color": "#3b82f6", # Blue for lines
-}
-
-
def generate_chart(
config: Dict[str, Any],
candles_data: List[Dict[str, Any]],
@@ -66,8 +51,68 @@ def generate_chart(
# Handle both list and dict input
data = candles_data if isinstance(candles_data, list) else candles_data.get("data", [])
- if not data:
- # Create empty chart with message
+ # Build title with side indicator
+ side_str = "LONG" if side == SIDE_LONG else "SHORT"
+ title = f"{trading_pair} - Grid Strike ({side_str})"
+
+ # Build horizontal lines for grid strike overlays
+ hlines = []
+
+ if start_price:
+ hlines.append({
+ "y": start_price,
+ "color": DARK_THEME["line_color"],
+ "dash": "dash",
+ "label": f"Start: {start_price:,.4f}",
+ "label_position": "right",
+ })
+
+ if end_price:
+ hlines.append({
+ "y": end_price,
+ "color": DARK_THEME["line_color"],
+ "dash": "dash",
+ "label": f"End: {end_price:,.4f}",
+ "label_position": "right",
+ })
+
+ if limit_price:
+ hlines.append({
+ "y": limit_price,
+ "color": DARK_THEME["down_color"],
+ "dash": "dot",
+ "label": f"Limit: {limit_price:,.4f}",
+ "label_position": "right",
+ })
+
+ # Build horizontal rectangles for grid zone
+ hrects = []
+
+ if start_price and end_price:
+ hrects.append({
+ "y0": min(start_price, end_price),
+ "y1": max(start_price, end_price),
+ "color": "rgba(59, 130, 246, 0.15)", # Light blue
+ "label": "Grid Zone",
+ })
+
+ # Use the unified candlestick chart function
+ result = generate_candlestick_chart(
+ candles=data,
+ title=title,
+ current_price=current_price,
+ show_volume=False, # Grid strike doesn't show volume
+ width=1100,
+ height=500,
+ hlines=hlines if hlines else None,
+ hrects=hrects if hrects else None,
+ reverse_data=False, # CEX data is already in chronological order
+ )
+
+ # Handle empty chart case
+ if result is None:
+ import plotly.graph_objects as go
+
fig = go.Figure()
fig.add_annotation(
text="No candle data available",
@@ -79,168 +124,19 @@ def generate_chart(
color=DARK_THEME["font_color"]
)
)
- else:
- # Extract OHLCV data
- timestamps = []
- datetime_objs = [] # Store datetime objects for intelligent tick labeling
- opens = []
- highs = []
- lows = []
- closes = []
-
- for candle in data:
- raw_ts = candle.get("timestamp", "")
- # Parse timestamp
- dt = None
- try:
- if isinstance(raw_ts, (int, float)):
- # Unix timestamp (seconds or milliseconds)
- if raw_ts > 1e12: # milliseconds
- dt = datetime.fromtimestamp(raw_ts / 1000)
- else:
- dt = datetime.fromtimestamp(raw_ts)
- elif isinstance(raw_ts, str) and raw_ts:
- # Try parsing ISO format
- if "T" in raw_ts:
- dt = datetime.fromisoformat(raw_ts.replace("Z", "+00:00"))
- else:
- dt = datetime.fromisoformat(raw_ts)
- except Exception:
- dt = None
-
- if dt:
- datetime_objs.append(dt)
- timestamps.append(dt) # Use datetime directly for x-axis
- else:
- timestamps.append(str(raw_ts))
- datetime_objs.append(None)
-
- opens.append(candle.get("open", 0))
- highs.append(candle.get("high", 0))
- lows.append(candle.get("low", 0))
- closes.append(candle.get("close", 0))
-
- # Create candlestick chart
- fig = go.Figure(data=[go.Candlestick(
- x=timestamps,
- open=opens,
- high=highs,
- low=lows,
- close=closes,
- increasing_line_color=DARK_THEME["up_color"],
- decreasing_line_color=DARK_THEME["down_color"],
- increasing_fillcolor=DARK_THEME["up_color"],
- decreasing_fillcolor=DARK_THEME["down_color"],
- name="Price"
- )])
-
- # Add grid zone overlay (shaded area between start and end)
- if start_price and end_price:
- fig.add_hrect(
- y0=min(start_price, end_price),
- y1=max(start_price, end_price),
- fillcolor="rgba(59, 130, 246, 0.15)", # Light blue
- line_width=0,
- annotation_text="Grid Zone",
- annotation_position="top left",
- annotation_font=dict(color=DARK_THEME["font_color"], size=11)
- )
-
- # Start price line
- fig.add_hline(
- y=start_price,
- line_dash="dash",
- line_color="#3b82f6",
- line_width=2,
- annotation_text=f"Start: {start_price:,.4f}",
- annotation_position="right",
- annotation_font=dict(color="#3b82f6", size=10)
- )
-
- # End price line
- fig.add_hline(
- y=end_price,
- line_dash="dash",
- line_color="#3b82f6",
- line_width=2,
- annotation_text=f"End: {end_price:,.4f}",
- annotation_position="right",
- annotation_font=dict(color="#3b82f6", size=10)
- )
-
- # Limit price line (stop loss)
- if limit_price:
- fig.add_hline(
- y=limit_price,
- line_dash="dot",
- line_color="#ef4444",
- line_width=2,
- annotation_text=f"Limit: {limit_price:,.4f}",
- annotation_position="right",
- annotation_font=dict(color="#ef4444", size=10)
- )
-
- # Current price line
- if current_price:
- fig.add_hline(
- y=current_price,
- line_dash="solid",
- line_color="#f59e0b",
- line_width=2,
- annotation_text=f"Current: {current_price:,.4f}",
- annotation_position="left",
- annotation_font=dict(color="#f59e0b", size=10)
- )
-
- # Build title with side indicator
- side_str = "LONG" if side == SIDE_LONG else "SHORT"
- title_text = f"{trading_pair} - Grid Strike ({side_str})"
-
- # Update layout with dark theme
- fig.update_layout(
- title=dict(
- text=title_text,
- font=dict(
- family=DARK_THEME["font_family"],
- size=18,
- color=DARK_THEME["font_color"]
- ),
- x=0.5,
- xanchor="center"
- ),
- paper_bgcolor=DARK_THEME["paper_bgcolor"],
- plot_bgcolor=DARK_THEME["plot_bgcolor"],
- font=dict(
- family=DARK_THEME["font_family"],
- color=DARK_THEME["font_color"]
- ),
- xaxis=dict(
- gridcolor=DARK_THEME["grid_color"],
- color=DARK_THEME["axis_color"],
- rangeslider_visible=False,
- showgrid=True,
- nticks=8, # Limit number of ticks to prevent crowding
- tickformat="%b %d\n%H:%M", # Multi-line format: "Dec 4" on first line, "20:00" on second
- tickangle=0, # Keep labels horizontal
- ),
- yaxis=dict(
- gridcolor=DARK_THEME["grid_color"],
- color=DARK_THEME["axis_color"],
- side="right",
- showgrid=True
- ),
- showlegend=False,
- width=900,
- height=500,
- margin=dict(l=10, r=120, t=50, b=50) # Increased bottom margin for multi-line x-axis labels
- )
+ fig.update_layout(
+ paper_bgcolor=DARK_THEME["paper_bgcolor"],
+ plot_bgcolor=DARK_THEME["plot_bgcolor"],
+ width=1100,
+ height=500,
+ )
- # Convert to PNG bytes
- img_bytes = io.BytesIO()
- fig.write_image(img_bytes, format='png', scale=2)
- img_bytes.seek(0)
+ img_bytes = io.BytesIO()
+ fig.write_image(img_bytes, format='png', scale=2)
+ img_bytes.seek(0)
+ return img_bytes
- return img_bytes
+ return result
def generate_preview_chart(
diff --git a/handlers/bots/controllers/grid_strike/config.py b/handlers/bots/controllers/grid_strike/config.py
index 2d472e8..23a67b1 100644
--- a/handlers/bots/controllers/grid_strike/config.py
+++ b/handlers/bots/controllers/grid_strike/config.py
@@ -42,13 +42,13 @@
"limit_price": 0.0,
"max_open_orders": 3,
"max_orders_per_batch": 1,
- "min_spread_between_orders": 0.0002,
+ "min_spread_between_orders": 0.0001,
"order_frequency": 3,
"activation_bounds": 0.01, # 1%
"keep_position": True,
"triple_barrier_config": {
"open_order_type": 3,
- "take_profit": 0.0001,
+ "take_profit": 0.0005,
"take_profit_order_type": 3,
},
}
@@ -148,16 +148,24 @@
label="Min Spread",
type="float",
required=False,
- hint="Default: 0.0002",
- default=0.0002
+ hint="Default: 0.0001",
+ default=0.0001
+ ),
+ "order_frequency": ControllerField(
+ name="order_frequency",
+ label="Order Frequency",
+ type="int",
+ required=False,
+ hint="Seconds between order placement (default: 3)",
+ default=3
),
"take_profit": ControllerField(
name="take_profit",
label="Take Profit",
type="float",
required=False,
- hint="Default: 0.0001",
- default=0.0001
+ hint="Default: 0.0005",
+ default=0.0005
),
"keep_position": ControllerField(
name="keep_position",
@@ -198,9 +206,9 @@
FIELD_ORDER: List[str] = [
"id", "connector_name", "trading_pair", "side", "leverage",
"total_amount_quote", "start_price", "end_price", "limit_price",
- "max_open_orders", "max_orders_per_batch", "min_order_amount_quote",
- "min_spread_between_orders", "take_profit", "open_order_type",
- "take_profit_order_type", "keep_position", "activation_bounds"
+ "max_open_orders", "max_orders_per_batch", "order_frequency",
+ "min_order_amount_quote", "min_spread_between_orders", "take_profit",
+ "open_order_type", "take_profit_order_type", "keep_position", "activation_bounds"
]
@@ -274,8 +282,8 @@ def calculate_auto_prices(
- limit_price: current_price - 3%
For SHORT:
- - start_price: current_price + 2%
- - end_price: current_price - 2%
+ - start_price: current_price - 2%
+ - end_price: current_price + 2%
- limit_price: current_price + 3%
Returns:
@@ -286,8 +294,8 @@ def calculate_auto_prices(
end_price = current_price * (1 + end_pct)
limit_price = current_price * (1 - limit_pct)
else: # SHORT
- start_price = current_price * (1 + start_pct)
- end_price = current_price * (1 - end_pct)
+ start_price = current_price * (1 - start_pct)
+ end_price = current_price * (1 + end_pct)
limit_price = current_price * (1 + limit_pct)
return (
diff --git a/handlers/bots/controllers/grid_strike/grid_analysis.py b/handlers/bots/controllers/grid_strike/grid_analysis.py
new file mode 100644
index 0000000..98ad8b1
--- /dev/null
+++ b/handlers/bots/controllers/grid_strike/grid_analysis.py
@@ -0,0 +1,405 @@
+"""
+Grid Strike analysis utilities.
+
+Provides:
+- NATR (Normalized ATR) calculation from candles
+- Volatility analysis for grid parameter suggestions
+- Theoretical grid generation with trading rules validation
+- Grid metrics calculation
+"""
+
+from typing import Any, Dict, List, Optional, Tuple
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def calculate_natr(candles: List[Dict[str, Any]], period: int = 14) -> Optional[float]:
+ """
+ Calculate Normalized Average True Range (NATR) from candles.
+
+ NATR = (ATR / Close) * 100, expressed as a percentage.
+
+ Args:
+ candles: List of candle dicts with high, low, close keys
+ period: ATR period (default 14)
+
+ Returns:
+ NATR as decimal (e.g., 0.025 for 2.5%), or None if insufficient data
+ """
+ if not candles or len(candles) < period + 1:
+ return None
+
+ # Calculate True Range for each candle
+ true_ranges = []
+ for i in range(1, len(candles)):
+ high = candles[i].get("high", 0)
+ low = candles[i].get("low", 0)
+ prev_close = candles[i - 1].get("close", 0)
+
+ if not all([high, low, prev_close]):
+ continue
+
+ # True Range = max(high - low, |high - prev_close|, |low - prev_close|)
+ tr = max(
+ high - low,
+ abs(high - prev_close),
+ abs(low - prev_close)
+ )
+ true_ranges.append(tr)
+
+ if len(true_ranges) < period:
+ return None
+
+ # Calculate ATR as simple moving average of TR
+ atr = sum(true_ranges[-period:]) / period
+
+ # Normalize by current close price
+ current_close = candles[-1].get("close", 0)
+ if current_close <= 0:
+ return None
+
+ natr = atr / current_close
+ return natr
+
+
+def calculate_price_stats(candles: List[Dict[str, Any]], lookback: int = 100) -> Dict[str, float]:
+ """
+ Calculate price statistics from candles.
+
+ Args:
+ candles: List of candle dicts
+ lookback: Number of candles to analyze
+
+ Returns:
+ Dict with price statistics:
+ - current_price: Latest close
+ - high_price: Highest high in period
+ - low_price: Lowest low in period
+ - range_pct: (high - low) / current as percentage
+ - avg_candle_range: Average (high-low)/close per candle
+ - natr_14: 14-period NATR
+ - natr_50: 50-period NATR (if enough data)
+ """
+ if not candles:
+ return {}
+
+ recent = candles[-lookback:] if len(candles) > lookback else candles
+
+ current_price = recent[-1].get("close", 0)
+ if current_price <= 0:
+ return {}
+
+ highs = [c.get("high", 0) for c in recent if c.get("high")]
+ lows = [c.get("low", 0) for c in recent if c.get("low")]
+
+ high_price = max(highs) if highs else current_price
+ low_price = min(lows) if lows else current_price
+
+ range_pct = (high_price - low_price) / current_price if current_price > 0 else 0
+
+ # Average candle range
+ candle_ranges = []
+ for c in recent:
+ h, l, close = c.get("high", 0), c.get("low", 0), c.get("close", 0)
+ if h and l and close:
+ candle_ranges.append((h - l) / close)
+ avg_candle_range = sum(candle_ranges) / len(candle_ranges) if candle_ranges else 0
+
+ return {
+ "current_price": current_price,
+ "high_price": high_price,
+ "low_price": low_price,
+ "range_pct": range_pct,
+ "avg_candle_range": avg_candle_range,
+ "natr_14": calculate_natr(candles, 14),
+ "natr_50": calculate_natr(candles, 50) if len(candles) >= 51 else None,
+ }
+
+
+def suggest_grid_params(
+ current_price: float,
+ natr: float,
+ side: int,
+ total_amount: float,
+ min_notional: float = 5.0,
+ min_price_increment: float = 0.0001,
+) -> Dict[str, Any]:
+ """
+ Suggest grid parameters based on volatility analysis.
+
+ Uses NATR to determine appropriate grid spacing and range.
+
+ Args:
+ current_price: Current market price
+ natr: Normalized ATR (as decimal, e.g., 0.02 for 2%)
+ side: 1 for LONG, 2 for SHORT
+ total_amount: Total amount in quote currency
+ min_notional: Minimum order value from trading rules
+ min_price_increment: Price tick size
+
+ Returns:
+ Dict with suggested parameters:
+ - start_price, end_price, limit_price
+ - min_spread_between_orders
+ - take_profit
+ - estimated_levels: Number of grid levels
+ - reasoning: Explanation of suggestions
+ """
+ if not natr or natr <= 0:
+ natr = 0.02 # Default 2% if no data
+
+ # Grid range based on NATR
+ # Use 3-5x daily NATR for the full grid range
+ # For 1m candles, NATR is per-minute, so scale appropriately
+ grid_range = natr * 3 # Grid covers ~3 NATR
+
+ # Minimum spread should be at least 1-2x NATR
+ suggested_spread = natr * 1.5
+
+ # Take profit should be smaller than spread
+ suggested_tp = natr * 0.5
+
+ # Ensure minimums
+ suggested_spread = max(suggested_spread, 0.0002) # At least 0.02%
+ suggested_tp = max(suggested_tp, 0.0001) # At least 0.01%
+
+ # Calculate prices based on side
+ if side == 1: # LONG
+ start_price = current_price * (1 - grid_range / 2)
+ end_price = current_price * (1 + grid_range / 2)
+ limit_price = start_price * (1 - grid_range / 3) # Stop below start
+ else: # SHORT
+ start_price = current_price * (1 - grid_range / 2)
+ end_price = current_price * (1 + grid_range / 2)
+ limit_price = end_price * (1 + grid_range / 3) # Stop above end
+
+ # Estimate number of levels
+ price_range = abs(end_price - start_price)
+ price_per_level = current_price * suggested_spread
+ estimated_levels = int(price_range / price_per_level) if price_per_level > 0 else 0
+
+ # Check if we have enough capital for the levels
+ min_levels = max(1, int(total_amount / min_notional))
+
+ reasoning = []
+ reasoning.append(f"NATR: {natr*100:.2f}%")
+ reasoning.append(f"Grid range: {grid_range*100:.1f}%")
+ reasoning.append(f"Est. levels: ~{estimated_levels}")
+
+ if estimated_levels > min_levels:
+ reasoning.append(f"Capital allows ~{min_levels} orders at ${min_notional:.0f} min")
+
+ return {
+ "start_price": round(start_price, 8),
+ "end_price": round(end_price, 8),
+ "limit_price": round(limit_price, 8),
+ "min_spread_between_orders": round(suggested_spread, 6),
+ "take_profit": round(suggested_tp, 6),
+ "estimated_levels": estimated_levels,
+ "reasoning": " | ".join(reasoning),
+ }
+
+
+def generate_theoretical_grid(
+ start_price: float,
+ end_price: float,
+ min_spread: float,
+ total_amount: float,
+ min_order_amount: float,
+ current_price: float,
+ side: int,
+ trading_rules: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ """
+ Generate theoretical grid levels matching the executor's _generate_grid_levels logic.
+
+ This implementation mirrors the actual GridStrikeExecutor._generate_grid_levels() method,
+ including proper base amount quantization and level calculation.
+
+ Args:
+ start_price: Grid start price
+ end_price: Grid end price
+ min_spread: Minimum spread between orders (as decimal)
+ total_amount: Total quote amount
+ min_order_amount: Minimum order amount in quote
+ current_price: Current market price
+ side: 1 for LONG, 2 for SHORT
+ trading_rules: Optional trading rules dict for validation
+
+ Returns:
+ Dict containing grid analysis results
+ """
+ import math
+
+ warnings = []
+
+ # Ensure proper ordering
+ low_price = min(start_price, end_price)
+ high_price = max(start_price, end_price)
+
+ if low_price <= 0 or high_price <= low_price or current_price <= 0:
+ return {
+ "levels": [],
+ "amount_per_level": 0,
+ "num_levels": 0,
+ "grid_range_pct": 0,
+ "warnings": ["Invalid price range"],
+ "valid": False,
+ }
+
+ # Calculate grid range as percentage (matches executor)
+ grid_range = (high_price - low_price) / low_price
+ grid_range_pct = grid_range * 100
+
+ # Get trading rules values with defaults
+ min_notional = min_order_amount
+ min_price_increment = 0.0001
+ min_base_increment = 0.0001
+
+ if trading_rules:
+ min_notional = max(min_order_amount, trading_rules.get("min_notional_size", 0))
+ min_price_increment = trading_rules.get("min_price_increment", 0.0001) or 0.0001
+ min_base_increment = trading_rules.get("min_base_amount_increment", 0.0001) or 0.0001
+
+ # Add safety margin (executor uses 1.05)
+ min_notional_with_margin = min_notional * 1.05
+
+ # Calculate minimum base amount that satisfies both min_notional and quantization
+ # (matches executor logic)
+ min_base_from_notional = min_notional_with_margin / current_price
+ min_base_from_quantization = min_base_increment * math.ceil(
+ min_notional / (min_base_increment * current_price)
+ )
+ min_base_amount = max(min_base_from_notional, min_base_from_quantization)
+
+ # Quantize the minimum base amount (round up to increment)
+ min_base_amount = math.ceil(min_base_amount / min_base_increment) * min_base_increment
+
+ # Calculate minimum quote amount from quantized base
+ min_quote_amount = min_base_amount * current_price
+
+ # Calculate minimum step size (matches executor)
+ min_step_size = max(
+ min_spread,
+ min_price_increment / current_price
+ )
+
+ # Calculate maximum possible levels based on total amount
+ max_possible_levels = int(total_amount / min_quote_amount) if min_quote_amount > 0 else 0
+
+ if max_possible_levels == 0:
+ return {
+ "levels": [],
+ "amount_per_level": 0,
+ "num_levels": 0,
+ "grid_range_pct": grid_range_pct,
+ "warnings": [f"Need ${min_quote_amount:.2f} min, have ${total_amount:.2f}"],
+ "valid": False,
+ }
+
+ # Calculate optimal number of levels (matches executor)
+ max_levels_by_step = int(grid_range / min_step_size) if min_step_size > 0 else max_possible_levels
+ n_levels = min(max_possible_levels, max_levels_by_step)
+
+ if n_levels == 0:
+ n_levels = 1
+ quote_amount_per_level = min_quote_amount
+ else:
+ # Calculate base amount per level with quantization (matches executor)
+ base_amount_per_level = max(
+ min_base_amount,
+ math.floor(total_amount / (current_price * n_levels) / min_base_increment) * min_base_increment
+ )
+ quote_amount_per_level = base_amount_per_level * current_price
+
+ # Adjust number of levels if total amount would be exceeded
+ n_levels = min(n_levels, int(total_amount / quote_amount_per_level))
+
+ # Ensure at least one level
+ n_levels = max(1, n_levels)
+
+ # Generate price levels with linear distribution (matches executor's Distributions.linear)
+ levels = []
+ if n_levels > 1:
+ for i in range(n_levels):
+ price = low_price + (high_price - low_price) * i / (n_levels - 1)
+ levels.append(round(price, 8))
+ step = grid_range / (n_levels - 1)
+ else:
+ mid_price = (low_price + high_price) / 2
+ levels.append(round(mid_price, 8))
+ step = grid_range
+
+ # Recalculate final amount per level
+ amount_per_level = total_amount / n_levels if n_levels > 0 else 0
+
+ # Validation warnings
+ if amount_per_level < min_notional:
+ warnings.append(f"${amount_per_level:.2f}/lvl < ${min_notional:.2f} min")
+
+ if trading_rules:
+ min_order_size = trading_rules.get("min_order_size", 0)
+ if min_order_size and current_price > 0:
+ base_per_level = amount_per_level / current_price
+ if base_per_level < min_order_size:
+ warnings.append(f"Below min size ({min_order_size})")
+
+ if n_levels > 1 and step < min_spread:
+ warnings.append(f"Spread {step*100:.3f}% < min {min_spread*100:.3f}%")
+
+ # Determine which levels are above/below current price
+ levels_below = [l for l in levels if l < current_price]
+ levels_above = [l for l in levels if l >= current_price]
+
+ return {
+ "levels": levels,
+ "levels_below_current": len(levels_below),
+ "levels_above_current": len(levels_above),
+ "amount_per_level": round(amount_per_level, 2),
+ "num_levels": n_levels,
+ "grid_range_pct": round(grid_range_pct, 3),
+ "price_step": round(step * low_price, 8) if n_levels > 1 else 0,
+ "spread_pct": round(step * 100, 3) if n_levels > 1 else round(min_spread * 100, 3),
+ "max_levels_by_budget": max_possible_levels,
+ "max_levels_by_spread": max_levels_by_step,
+ "warnings": warnings,
+ "valid": len(warnings) == 0,
+ }
+
+
+def format_grid_summary(
+ grid: Dict[str, Any],
+ natr: Optional[float] = None,
+ take_profit: float = 0.0001,
+) -> str:
+ """
+ Format grid analysis for display.
+
+ Args:
+ grid: Grid dict from generate_theoretical_grid
+ natr: Optional NATR value
+ take_profit: Take profit percentage (as decimal)
+
+ Returns:
+ Formatted summary string (not escaped for markdown)
+ """
+ lines = []
+
+ # Grid levels info
+ lines.append(f"Levels: {grid['num_levels']}")
+ lines.append(f" Below current: {grid.get('levels_below_current', 0)}")
+ lines.append(f" Above current: {grid.get('levels_above_current', 0)}")
+ lines.append(f"Amount/level: ${grid['amount_per_level']:.2f}")
+ lines.append(f"Spread: {grid.get('spread_pct', 0):.3f}%")
+ lines.append(f"Take Profit: {take_profit*100:.3f}%")
+
+ if natr:
+ lines.append(f"NATR (14): {natr*100:.2f}%")
+
+ if grid.get("warnings"):
+ lines.append("Warnings:")
+ for w in grid["warnings"]:
+ lines.append(f" - {w}")
+
+ return "\n".join(lines)
diff --git a/handlers/bots/controllers/pmm_mister/__init__.py b/handlers/bots/controllers/pmm_mister/__init__.py
new file mode 100644
index 0000000..ca252eb
--- /dev/null
+++ b/handlers/bots/controllers/pmm_mister/__init__.py
@@ -0,0 +1,116 @@
+"""
+PMM Mister Controller Module
+
+Provides configuration, validation, and visualization for PMM (Pure Market Making) controllers.
+
+PMM Mister is an advanced market making strategy that:
+- Places buy/sell orders at configurable spread levels
+- Manages position with target/min/max base percentages
+- Features hanging executors and breakeven awareness
+- Supports price distance requirements and cooldowns
+"""
+
+import io
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import BaseController, ControllerField
+from .config import (
+ DEFAULTS,
+ FIELDS,
+ FIELD_ORDER,
+ WIZARD_STEPS,
+ ORDER_TYPE_MARKET,
+ ORDER_TYPE_LIMIT,
+ ORDER_TYPE_LIMIT_MAKER,
+ ORDER_TYPE_LABELS,
+ validate_config,
+ generate_id,
+ parse_spreads,
+ format_spreads,
+)
+from .chart import generate_chart, generate_preview_chart
+from .pmm_analysis import (
+ calculate_natr,
+ calculate_price_stats,
+ suggest_pmm_params,
+ generate_theoretical_levels,
+ format_pmm_summary,
+ calculate_effective_spread,
+)
+
+
+class PmmMisterController(BaseController):
+ """PMM Mister controller implementation."""
+
+ controller_type = "pmm_mister"
+ display_name = "PMM Mister"
+ description = "Advanced pure market making with position management"
+
+ @classmethod
+ def get_defaults(cls) -> Dict[str, Any]:
+ """Get default configuration values."""
+ return DEFAULTS.copy()
+
+ @classmethod
+ def get_fields(cls) -> Dict[str, ControllerField]:
+ """Get field definitions."""
+ return FIELDS
+
+ @classmethod
+ def get_field_order(cls) -> List[str]:
+ """Get field display order."""
+ return FIELD_ORDER
+
+ @classmethod
+ def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ """Validate configuration."""
+ return validate_config(config)
+
+ @classmethod
+ def generate_chart(
+ cls,
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None
+ ) -> io.BytesIO:
+ """Generate visualization chart."""
+ return generate_chart(config, candles_data, current_price)
+
+ @classmethod
+ def generate_id(
+ cls,
+ config: Dict[str, Any],
+ existing_configs: List[Dict[str, Any]]
+ ) -> str:
+ """Generate unique ID with sequence number."""
+ return generate_id(config, existing_configs)
+
+
+# Export commonly used items at module level
+__all__ = [
+ # Controller class
+ "PmmMisterController",
+ # Config
+ "DEFAULTS",
+ "FIELDS",
+ "FIELD_ORDER",
+ "WIZARD_STEPS",
+ "ORDER_TYPE_MARKET",
+ "ORDER_TYPE_LIMIT",
+ "ORDER_TYPE_LIMIT_MAKER",
+ "ORDER_TYPE_LABELS",
+ # Functions
+ "validate_config",
+ "generate_id",
+ "parse_spreads",
+ "format_spreads",
+ "generate_chart",
+ "generate_preview_chart",
+ # PMM analysis
+ "calculate_natr",
+ "calculate_price_stats",
+ "suggest_pmm_params",
+ "generate_theoretical_levels",
+ "format_pmm_summary",
+ "calculate_effective_spread",
+]
diff --git a/handlers/bots/controllers/pmm_mister/chart.py b/handlers/bots/controllers/pmm_mister/chart.py
new file mode 100644
index 0000000..a9bdcb6
--- /dev/null
+++ b/handlers/bots/controllers/pmm_mister/chart.py
@@ -0,0 +1,164 @@
+"""
+PMM Mister chart generation.
+
+Generates candlestick charts with PMM spread visualization:
+- Buy spread levels (green dashed lines)
+- Sell spread levels (red dashed lines)
+- Current price line
+- Take profit zone indicator
+
+Uses the unified candlestick chart function from visualizations module.
+"""
+
+import io
+from typing import Any, Dict, List, Optional
+
+from handlers.dex.visualizations import generate_candlestick_chart, DARK_THEME
+from .config import parse_spreads
+
+
+def generate_chart(
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None
+) -> io.BytesIO:
+ """
+ Generate a candlestick chart with PMM spread overlay.
+
+ The chart shows:
+ - Candlestick price data
+ - Buy spread levels (green dashed lines below price)
+ - Sell spread levels (red dashed lines above price)
+ - Take profit zone (shaded area around current price)
+ - Current price line (orange solid)
+
+ Args:
+ config: PMM Mister configuration with spreads, take_profit, etc.
+ candles_data: List of candles from API (each with open, high, low, close, timestamp)
+ current_price: Current market price
+
+ Returns:
+ BytesIO object containing the PNG image
+ """
+ trading_pair = config.get("trading_pair", "Unknown")
+ buy_spreads_str = config.get("buy_spreads", "0.01,0.02")
+ sell_spreads_str = config.get("sell_spreads", "0.01,0.02")
+ take_profit = float(config.get("take_profit", 0.0001))
+
+ # Parse spreads
+ buy_spreads = parse_spreads(buy_spreads_str)
+ sell_spreads = parse_spreads(sell_spreads_str)
+
+ # Handle both list and dict input
+ data = candles_data if isinstance(candles_data, list) else candles_data.get("data", [])
+
+ # Build title
+ title = f"{trading_pair} - PMM Mister"
+
+ # Get reference price for spread calculations
+ ref_price = current_price
+ if not ref_price and data:
+ # Use last close if no current price provided
+ last_candle = data[-1] if isinstance(data[-1], dict) else None
+ if last_candle:
+ ref_price = last_candle.get("close", 0)
+
+ # Build horizontal lines for spread overlays
+ hlines = []
+
+ # Add buy spread levels (below current price)
+ if ref_price and buy_spreads:
+ for i, spread in enumerate(buy_spreads):
+ buy_price = ref_price * (1 - spread)
+ # Fade opacity for further levels
+ opacity_suffix = "" if i == 0 else f" (L{i+1})"
+ hlines.append({
+ "y": buy_price,
+ "color": DARK_THEME["up_color"],
+ "dash": "dash",
+ "width": 2 if i == 0 else 1,
+ "label": f"Buy{opacity_suffix}: {buy_price:,.4f} (-{spread*100:.1f}%)",
+ "label_position": "left",
+ })
+
+ # Add sell spread levels (above current price)
+ if ref_price and sell_spreads:
+ for i, spread in enumerate(sell_spreads):
+ sell_price = ref_price * (1 + spread)
+ opacity_suffix = "" if i == 0 else f" (L{i+1})"
+ hlines.append({
+ "y": sell_price,
+ "color": DARK_THEME["down_color"],
+ "dash": "dash",
+ "width": 2 if i == 0 else 1,
+ "label": f"Sell{opacity_suffix}: {sell_price:,.4f} (+{spread*100:.1f}%)",
+ "label_position": "right",
+ })
+
+ # Build horizontal rectangles for take profit zone
+ hrects = []
+ if ref_price and take_profit:
+ tp_up = ref_price * (1 + take_profit)
+ tp_down = ref_price * (1 - take_profit)
+ hrects.append({
+ "y0": tp_down,
+ "y1": tp_up,
+ "color": "rgba(245, 158, 11, 0.1)", # Light orange
+ "label": f"TP Zone ({take_profit*100:.2f}%)",
+ })
+
+ # Use the unified candlestick chart function
+ result = generate_candlestick_chart(
+ candles=data,
+ title=title,
+ current_price=current_price,
+ show_volume=False, # PMM chart doesn't show volume
+ width=1100,
+ height=500,
+ hlines=hlines if hlines else None,
+ hrects=hrects if hrects else None,
+ reverse_data=False, # CEX data is already in chronological order
+ )
+
+ # Handle empty chart case
+ if result is None:
+ import plotly.graph_objects as go
+
+ fig = go.Figure()
+ fig.add_annotation(
+ text="No candle data available",
+ xref="paper", yref="paper",
+ x=0.5, y=0.5, showarrow=False,
+ font=dict(
+ family=DARK_THEME["font_family"],
+ size=16,
+ color=DARK_THEME["font_color"]
+ )
+ )
+ fig.update_layout(
+ paper_bgcolor=DARK_THEME["paper_bgcolor"],
+ plot_bgcolor=DARK_THEME["plot_bgcolor"],
+ width=1100,
+ height=500,
+ )
+
+ img_bytes = io.BytesIO()
+ fig.write_image(img_bytes, format='png', scale=2)
+ img_bytes.seek(0)
+ return img_bytes
+
+ return result
+
+
+def generate_preview_chart(
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None
+) -> io.BytesIO:
+ """
+ Generate a smaller preview chart for config viewing.
+
+ Same as generate_chart but with smaller dimensions.
+ """
+ # Use the same logic but we could customize dimensions here if needed
+ return generate_chart(config, candles_data, current_price)
diff --git a/handlers/bots/controllers/pmm_mister/config.py b/handlers/bots/controllers/pmm_mister/config.py
new file mode 100644
index 0000000..5966e44
--- /dev/null
+++ b/handlers/bots/controllers/pmm_mister/config.py
@@ -0,0 +1,383 @@
+"""
+PMM Mister controller configuration.
+
+Contains defaults, field definitions, and validation for PMM (Pure Market Making) controllers.
+Features hanging executors, price distance requirements, and breakeven awareness.
+"""
+
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import ControllerField
+
+
+# Order type mapping
+ORDER_TYPE_MARKET = 1
+ORDER_TYPE_LIMIT = 2
+ORDER_TYPE_LIMIT_MAKER = 3
+
+ORDER_TYPE_LABELS = {
+ ORDER_TYPE_MARKET: "Market",
+ ORDER_TYPE_LIMIT: "Limit",
+ ORDER_TYPE_LIMIT_MAKER: "Limit Maker",
+}
+
+
+# Default configuration values
+DEFAULTS: Dict[str, Any] = {
+ "controller_name": "pmm_mister",
+ "controller_type": "generic",
+ "id": "",
+ "connector_name": "",
+ "trading_pair": "",
+ "leverage": 20,
+ "position_mode": "HEDGE",
+ "portfolio_allocation": 0.05,
+ "target_base_pct": 0.2,
+ "min_base_pct": 0.1,
+ "max_base_pct": 0.4,
+ "buy_spreads": "0.0002,0.001",
+ "sell_spreads": "0.0002,0.001",
+ "buy_amounts_pct": "1,2",
+ "sell_amounts_pct": "1,2",
+ "executor_refresh_time": 30,
+ "buy_cooldown_time": 15,
+ "sell_cooldown_time": 15,
+ "buy_position_effectivization_time": 60,
+ "sell_position_effectivization_time": 60,
+ "min_buy_price_distance_pct": 0.003,
+ "min_sell_price_distance_pct": 0.003,
+ "take_profit": 0.0001,
+ "take_profit_order_type": ORDER_TYPE_LIMIT_MAKER,
+ "max_active_executors_by_level": 4,
+ "tick_mode": False,
+ "candles_config": [],
+}
+
+
+# Field definitions for form
+FIELDS: Dict[str, ControllerField] = {
+ "id": ControllerField(
+ name="id",
+ label="Config ID",
+ type="str",
+ required=True,
+ hint="Auto-generated with sequence number"
+ ),
+ "connector_name": ControllerField(
+ name="connector_name",
+ label="Connector",
+ type="str",
+ required=True,
+ hint="Select from available exchanges"
+ ),
+ "trading_pair": ControllerField(
+ name="trading_pair",
+ label="Trading Pair",
+ type="str",
+ required=True,
+ hint="e.g. BTC-FDUSD, ETH-USDT"
+ ),
+ "leverage": ControllerField(
+ name="leverage",
+ label="Leverage",
+ type="int",
+ required=True,
+ hint="e.g. 1, 10, 20",
+ default=20
+ ),
+ "portfolio_allocation": ControllerField(
+ name="portfolio_allocation",
+ label="Portfolio Allocation",
+ type="float",
+ required=True,
+ hint="Fraction of portfolio (e.g. 0.05 = 5%)",
+ default=0.05
+ ),
+ "target_base_pct": ControllerField(
+ name="target_base_pct",
+ label="Target Base %",
+ type="float",
+ required=True,
+ hint="Target base asset percentage (e.g. 0.2 = 20%)",
+ default=0.2
+ ),
+ "min_base_pct": ControllerField(
+ name="min_base_pct",
+ label="Min Base %",
+ type="float",
+ required=False,
+ hint="Minimum base % before buying (default: 0.1)",
+ default=0.1
+ ),
+ "max_base_pct": ControllerField(
+ name="max_base_pct",
+ label="Max Base %",
+ type="float",
+ required=False,
+ hint="Maximum base % before selling (default: 0.4)",
+ default=0.4
+ ),
+ "buy_spreads": ControllerField(
+ name="buy_spreads",
+ label="Buy Spreads",
+ type="str",
+ required=True,
+ hint="Comma-separated spreads (e.g. 0.0002,0.001)",
+ default="0.0002,0.001"
+ ),
+ "sell_spreads": ControllerField(
+ name="sell_spreads",
+ label="Sell Spreads",
+ type="str",
+ required=True,
+ hint="Comma-separated spreads (e.g. 0.0002,0.001)",
+ default="0.0002,0.001"
+ ),
+ "buy_amounts_pct": ControllerField(
+ name="buy_amounts_pct",
+ label="Buy Amounts %",
+ type="str",
+ required=False,
+ hint="Comma-separated amounts (e.g. 1,2)",
+ default="1,2"
+ ),
+ "sell_amounts_pct": ControllerField(
+ name="sell_amounts_pct",
+ label="Sell Amounts %",
+ type="str",
+ required=False,
+ hint="Comma-separated amounts (e.g. 1,2)",
+ default="1,2"
+ ),
+ "take_profit": ControllerField(
+ name="take_profit",
+ label="Take Profit",
+ type="float",
+ required=True,
+ hint="Take profit percentage (e.g. 0.0001 = 0.01%)",
+ default=0.0001
+ ),
+ "take_profit_order_type": ControllerField(
+ name="take_profit_order_type",
+ label="TP Order Type",
+ type="int",
+ required=False,
+ hint="Order type for take profit",
+ default=ORDER_TYPE_LIMIT_MAKER
+ ),
+ "executor_refresh_time": ControllerField(
+ name="executor_refresh_time",
+ label="Refresh Time (s)",
+ type="int",
+ required=False,
+ hint="Executor refresh interval (default: 30)",
+ default=30
+ ),
+ "buy_cooldown_time": ControllerField(
+ name="buy_cooldown_time",
+ label="Buy Cooldown (s)",
+ type="int",
+ required=False,
+ hint="Cooldown between buy orders (default: 15)",
+ default=15
+ ),
+ "sell_cooldown_time": ControllerField(
+ name="sell_cooldown_time",
+ label="Sell Cooldown (s)",
+ type="int",
+ required=False,
+ hint="Cooldown between sell orders (default: 15)",
+ default=15
+ ),
+ "buy_position_effectivization_time": ControllerField(
+ name="buy_position_effectivization_time",
+ label="Buy Effect. Time (s)",
+ type="int",
+ required=False,
+ hint="Time to effectivize buy positions (default: 60)",
+ default=60
+ ),
+ "sell_position_effectivization_time": ControllerField(
+ name="sell_position_effectivization_time",
+ label="Sell Effect. Time (s)",
+ type="int",
+ required=False,
+ hint="Time to effectivize sell positions (default: 60)",
+ default=60
+ ),
+ "min_buy_price_distance_pct": ControllerField(
+ name="min_buy_price_distance_pct",
+ label="Min Buy Distance %",
+ type="float",
+ required=False,
+ hint="Min price distance for buys (default: 0.003)",
+ default=0.003
+ ),
+ "min_sell_price_distance_pct": ControllerField(
+ name="min_sell_price_distance_pct",
+ label="Min Sell Distance %",
+ type="float",
+ required=False,
+ hint="Min price distance for sells (default: 0.003)",
+ default=0.003
+ ),
+ "max_active_executors_by_level": ControllerField(
+ name="max_active_executors_by_level",
+ label="Max Executors/Level",
+ type="int",
+ required=False,
+ hint="Max active executors per level (default: 4)",
+ default=4
+ ),
+ "tick_mode": ControllerField(
+ name="tick_mode",
+ label="Tick Mode",
+ type="bool",
+ required=False,
+ hint="Enable tick-based updates",
+ default=False
+ ),
+}
+
+
+# Field display order
+FIELD_ORDER: List[str] = [
+ "id", "connector_name", "trading_pair", "leverage",
+ "portfolio_allocation", "target_base_pct", "min_base_pct", "max_base_pct",
+ "buy_spreads", "sell_spreads", "buy_amounts_pct", "sell_amounts_pct",
+ "take_profit", "take_profit_order_type",
+ "executor_refresh_time", "buy_cooldown_time", "sell_cooldown_time",
+ "buy_position_effectivization_time", "sell_position_effectivization_time",
+ "min_buy_price_distance_pct", "min_sell_price_distance_pct",
+ "max_active_executors_by_level", "tick_mode"
+]
+
+
+# Wizard steps - prompts only the most important fields
+WIZARD_STEPS: List[str] = [
+ "connector_name",
+ "trading_pair",
+ "leverage",
+ "portfolio_allocation",
+ "base_percentages", # Combined: target/min/max base pct
+ "spreads", # Combined: buy/sell spreads
+ "take_profit",
+ "review",
+]
+
+
+def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ """
+ Validate a PMM Mister configuration.
+
+ Checks:
+ - Required fields are present
+ - Base percentages are valid (min < target < max)
+ - Spreads are properly formatted
+ - Values are within reasonable bounds
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ """
+ # Check required fields
+ required = ["connector_name", "trading_pair"]
+ for field in required:
+ if not config.get(field):
+ return False, f"Missing required field: {field}"
+
+ # Validate base percentages
+ min_base = float(config.get("min_base_pct", 0.1))
+ target_base = float(config.get("target_base_pct", 0.2))
+ max_base = float(config.get("max_base_pct", 0.4))
+
+ if not (0 <= min_base < target_base < max_base <= 1):
+ return False, (
+ f"Invalid base percentages: require 0 <= min < target < max <= 1. "
+ f"Got: min={min_base}, target={target_base}, max={max_base}"
+ )
+
+ # Validate portfolio allocation
+ allocation = float(config.get("portfolio_allocation", 0.05))
+ if not (0 < allocation <= 1):
+ return False, f"Portfolio allocation must be between 0 and 1, got: {allocation}"
+
+ # Validate spreads format
+ for spread_field in ["buy_spreads", "sell_spreads"]:
+ spreads = config.get(spread_field, "")
+ if spreads:
+ try:
+ if isinstance(spreads, str):
+ values = [float(x.strip()) for x in spreads.split(",")]
+ else:
+ values = [float(x) for x in spreads]
+ if not all(v > 0 for v in values):
+ return False, f"{spread_field} must contain positive values"
+ except ValueError:
+ return False, f"Invalid format for {spread_field}: {spreads}"
+
+ # Validate take profit
+ take_profit = config.get("take_profit")
+ if take_profit is not None:
+ try:
+ tp = float(take_profit)
+ if tp <= 0:
+ return False, "Take profit must be positive"
+ except (ValueError, TypeError):
+ return False, f"Invalid take profit value: {take_profit}"
+
+ return True, None
+
+
+def parse_spreads(spread_str: str) -> List[float]:
+ """Parse comma-separated spread string to list of floats."""
+ if not spread_str:
+ return []
+ if isinstance(spread_str, list):
+ return [float(x) for x in spread_str]
+ return [float(x.strip()) for x in spread_str.split(",")]
+
+
+def format_spreads(spreads: List[float]) -> str:
+ """Format list of spreads to comma-separated string."""
+ return ",".join(str(x) for x in spreads)
+
+
+def generate_id(
+ config: Dict[str, Any],
+ existing_configs: List[Dict[str, Any]]
+) -> str:
+ """
+ Generate a unique config ID with sequential numbering.
+
+ Format: NNN_pmm_connector_pair
+ Example: 001_pmm_binance_BTC-FDUSD
+
+ Args:
+ config: The configuration being created
+ existing_configs: List of existing configurations
+
+ Returns:
+ Generated config ID
+ """
+ # Get next sequence number
+ max_num = 0
+ for cfg in existing_configs:
+ config_id = cfg.get("id", "")
+ if not config_id:
+ continue
+ parts = config_id.split("_", 1)
+ if parts and parts[0].isdigit():
+ num = int(parts[0])
+ max_num = max(max_num, num)
+
+ next_num = max_num + 1
+ seq = str(next_num).zfill(3)
+
+ # Clean connector name
+ connector = config.get("connector_name", "unknown")
+ conn_clean = connector.replace("_perpetual", "").replace("_spot", "")
+
+ # Get trading pair
+ pair = config.get("trading_pair", "UNKNOWN").upper()
+
+ return f"{seq}_pmm_{conn_clean}_{pair}"
diff --git a/handlers/bots/controllers/pmm_mister/pmm_analysis.py b/handlers/bots/controllers/pmm_mister/pmm_analysis.py
new file mode 100644
index 0000000..34361e9
--- /dev/null
+++ b/handlers/bots/controllers/pmm_mister/pmm_analysis.py
@@ -0,0 +1,399 @@
+"""
+PMM Mister analysis utilities.
+
+Provides:
+- NATR (Normalized ATR) calculation from candles
+- Volatility analysis for spread parameter suggestions
+- Theoretical spread level generation
+- PMM metrics calculation and summary formatting
+"""
+
+from typing import Any, Dict, List, Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def calculate_natr(candles: List[Dict[str, Any]], period: int = 14) -> Optional[float]:
+ """
+ Calculate Normalized Average True Range (NATR) from candles.
+
+ NATR = (ATR / Close) * 100, expressed as a percentage.
+
+ Args:
+ candles: List of candle dicts with high, low, close keys
+ period: ATR period (default 14)
+
+ Returns:
+ NATR as decimal (e.g., 0.025 for 2.5%), or None if insufficient data
+ """
+ if not candles or len(candles) < period + 1:
+ return None
+
+ # Calculate True Range for each candle
+ true_ranges = []
+ for i in range(1, len(candles)):
+ high = candles[i].get("high", 0)
+ low = candles[i].get("low", 0)
+ prev_close = candles[i - 1].get("close", 0)
+
+ if not all([high, low, prev_close]):
+ continue
+
+ # True Range = max(high - low, |high - prev_close|, |low - prev_close|)
+ tr = max(
+ high - low,
+ abs(high - prev_close),
+ abs(low - prev_close)
+ )
+ true_ranges.append(tr)
+
+ if len(true_ranges) < period:
+ return None
+
+ # Calculate ATR as simple moving average of TR
+ atr = sum(true_ranges[-period:]) / period
+
+ # Normalize by current close price
+ current_close = candles[-1].get("close", 0)
+ if current_close <= 0:
+ return None
+
+ natr = atr / current_close
+ return natr
+
+
+def calculate_price_stats(candles: List[Dict[str, Any]], lookback: int = 100) -> Dict[str, float]:
+ """
+ Calculate price statistics from candles.
+
+ Args:
+ candles: List of candle dicts
+ lookback: Number of candles to analyze
+
+ Returns:
+ Dict with price statistics:
+ - current_price: Latest close
+ - high_price: Highest high in period
+ - low_price: Lowest low in period
+ - range_pct: (high - low) / current as percentage
+ - avg_candle_range: Average (high-low)/close per candle
+ - natr_14: 14-period NATR
+ - natr_50: 50-period NATR (if enough data)
+ """
+ if not candles:
+ return {}
+
+ recent = candles[-lookback:] if len(candles) > lookback else candles
+
+ current_price = recent[-1].get("close", 0)
+ if current_price <= 0:
+ return {}
+
+ highs = [c.get("high", 0) for c in recent if c.get("high")]
+ lows = [c.get("low", 0) for c in recent if c.get("low")]
+
+ high_price = max(highs) if highs else current_price
+ low_price = min(lows) if lows else current_price
+
+ range_pct = (high_price - low_price) / current_price if current_price > 0 else 0
+
+ # Average candle range
+ candle_ranges = []
+ for c in recent:
+ h, l, close = c.get("high", 0), c.get("low", 0), c.get("close", 0)
+ if h and l and close:
+ candle_ranges.append((h - l) / close)
+ avg_candle_range = sum(candle_ranges) / len(candle_ranges) if candle_ranges else 0
+
+ return {
+ "current_price": current_price,
+ "high_price": high_price,
+ "low_price": low_price,
+ "range_pct": range_pct,
+ "avg_candle_range": avg_candle_range,
+ "natr_14": calculate_natr(candles, 14),
+ "natr_50": calculate_natr(candles, 50) if len(candles) >= 51 else None,
+ }
+
+
+def suggest_pmm_params(
+ current_price: float,
+ natr: float,
+ portfolio_value: float,
+ allocation_pct: float = 0.05,
+ min_notional: float = 5.0,
+) -> Dict[str, Any]:
+ """
+ Suggest PMM parameters based on volatility analysis.
+
+ Uses NATR to determine appropriate spread levels and take profit.
+
+ Args:
+ current_price: Current market price
+ natr: Normalized ATR (as decimal, e.g., 0.02 for 2%)
+ portfolio_value: Total portfolio value in quote currency
+ allocation_pct: Fraction of portfolio to allocate
+ min_notional: Minimum order value from trading rules
+
+ Returns:
+ Dict with suggested parameters:
+ - buy_spreads: Suggested buy spread levels
+ - sell_spreads: Suggested sell spread levels
+ - take_profit: Suggested take profit
+ - min_price_distance_pct: Suggested min price distance
+ - reasoning: Explanation of suggestions
+ """
+ if not natr or natr <= 0:
+ natr = 0.02 # Default 2% if no data
+
+ # First spread level should be slightly above NATR to avoid immediate fills
+ # Second spread level should be 2-3x NATR for deeper liquidity
+ first_spread = natr * 1.2 # ~120% of NATR
+ second_spread = natr * 2.5 # ~250% of NATR
+
+ # Ensure minimums
+ first_spread = max(first_spread, 0.0002) # At least 0.02%
+ second_spread = max(second_spread, 0.001) # At least 0.1%
+
+ # Take profit should be fraction of first spread
+ suggested_tp = first_spread * 0.3
+ suggested_tp = max(suggested_tp, 0.0001) # At least 0.01%
+
+ # Min price distance should be close to first spread
+ min_price_distance = first_spread * 0.8
+ min_price_distance = max(min_price_distance, 0.001) # At least 0.1%
+
+ # Calculate position sizing
+ allocated_amount = portfolio_value * allocation_pct
+ estimated_orders = int(allocated_amount / min_notional) if min_notional > 0 else 0
+
+ reasoning = []
+ reasoning.append(f"NATR: {natr*100:.2f}%")
+ reasoning.append(f"L1 spread: {first_spread*100:.2f}%")
+ reasoning.append(f"L2 spread: {second_spread*100:.2f}%")
+ reasoning.append(f"Allocation: ${allocated_amount:,.0f}")
+
+ if estimated_orders > 0:
+ reasoning.append(f"Est. orders: ~{estimated_orders}")
+
+ return {
+ "buy_spreads": f"{round(first_spread, 4)},{round(second_spread, 4)}",
+ "sell_spreads": f"{round(first_spread, 4)},{round(second_spread, 4)}",
+ "take_profit": round(suggested_tp, 6),
+ "min_buy_price_distance_pct": round(min_price_distance, 4),
+ "min_sell_price_distance_pct": round(min_price_distance, 4),
+ "estimated_orders": estimated_orders,
+ "reasoning": " | ".join(reasoning),
+ }
+
+
+def generate_theoretical_levels(
+ current_price: float,
+ buy_spreads: List[float],
+ sell_spreads: List[float],
+ take_profit: float,
+ portfolio_value: float,
+ allocation_pct: float,
+ buy_amounts_pct: Optional[List[float]] = None,
+ sell_amounts_pct: Optional[List[float]] = None,
+ min_notional: float = 5.0,
+ trading_rules: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ """
+ Generate theoretical PMM spread levels and order amounts.
+
+ Args:
+ current_price: Current market price
+ buy_spreads: List of buy spread percentages (as decimals)
+ sell_spreads: List of sell spread percentages (as decimals)
+ take_profit: Take profit percentage (as decimal)
+ portfolio_value: Total portfolio value
+ allocation_pct: Portfolio allocation fraction
+ buy_amounts_pct: Buy amount percentages per level
+ sell_amounts_pct: Sell amount percentages per level
+ min_notional: Minimum order notional
+ trading_rules: Optional trading rules dict
+
+ Returns:
+ Dict containing level analysis results
+ """
+ warnings = []
+
+ if current_price <= 0 or portfolio_value <= 0:
+ return {
+ "buy_levels": [],
+ "sell_levels": [],
+ "total_buy_amount": 0,
+ "total_sell_amount": 0,
+ "warnings": ["Invalid price or portfolio value"],
+ "valid": False,
+ }
+
+ allocated_amount = portfolio_value * allocation_pct
+
+ # Get trading rules values with defaults
+ min_notional_val = min_notional
+ if trading_rules:
+ min_notional_val = max(min_notional, trading_rules.get("min_notional_size", 0))
+
+ # Default amount percentages if not provided
+ if not buy_amounts_pct:
+ buy_amounts_pct = [1.0] * len(buy_spreads)
+ if not sell_amounts_pct:
+ sell_amounts_pct = [1.0] * len(sell_spreads)
+
+ # Normalize amounts
+ total_buy_pct = sum(buy_amounts_pct)
+ total_sell_pct = sum(sell_amounts_pct)
+
+ # Generate buy levels (below current price)
+ buy_levels = []
+ total_buy_amount = 0
+ for i, spread in enumerate(buy_spreads):
+ price = current_price * (1 - spread)
+ pct = buy_amounts_pct[i] if i < len(buy_amounts_pct) else 1.0
+ amount = (allocated_amount / 2) * (pct / total_buy_pct) if total_buy_pct > 0 else 0
+ total_buy_amount += amount
+
+ level = {
+ "level": i + 1,
+ "price": round(price, 8),
+ "spread_pct": round(spread * 100, 3),
+ "amount_quote": round(amount, 2),
+ "tp_price": round(price * (1 + take_profit), 8),
+ }
+ buy_levels.append(level)
+
+ if amount < min_notional_val:
+ warnings.append(f"Buy L{i+1}: ${amount:.2f} < ${min_notional_val:.2f} min")
+
+ # Generate sell levels (above current price)
+ sell_levels = []
+ total_sell_amount = 0
+ for i, spread in enumerate(sell_spreads):
+ price = current_price * (1 + spread)
+ pct = sell_amounts_pct[i] if i < len(sell_amounts_pct) else 1.0
+ amount = (allocated_amount / 2) * (pct / total_sell_pct) if total_sell_pct > 0 else 0
+ total_sell_amount += amount
+
+ level = {
+ "level": i + 1,
+ "price": round(price, 8),
+ "spread_pct": round(spread * 100, 3),
+ "amount_quote": round(amount, 2),
+ "tp_price": round(price * (1 - take_profit), 8),
+ }
+ sell_levels.append(level)
+
+ if amount < min_notional_val:
+ warnings.append(f"Sell L{i+1}: ${amount:.2f} < ${min_notional_val:.2f} min")
+
+ # Validate take profit vs spread
+ if buy_spreads and take_profit >= min(buy_spreads):
+ warnings.append(f"TP {take_profit*100:.2f}% >= min spread {min(buy_spreads)*100:.2f}%")
+ if sell_spreads and take_profit >= min(sell_spreads):
+ warnings.append(f"TP {take_profit*100:.2f}% >= min spread {min(sell_spreads)*100:.2f}%")
+
+ return {
+ "buy_levels": buy_levels,
+ "sell_levels": sell_levels,
+ "total_buy_amount": round(total_buy_amount, 2),
+ "total_sell_amount": round(total_sell_amount, 2),
+ "total_allocated": round(allocated_amount, 2),
+ "num_buy_levels": len(buy_levels),
+ "num_sell_levels": len(sell_levels),
+ "warnings": warnings,
+ "valid": len(warnings) == 0,
+ }
+
+
+def format_pmm_summary(
+ levels: Dict[str, Any],
+ natr: Optional[float] = None,
+ take_profit: float = 0.0001,
+) -> str:
+ """
+ Format PMM analysis for display.
+
+ Args:
+ levels: Levels dict from generate_theoretical_levels
+ natr: Optional NATR value
+ take_profit: Take profit percentage (as decimal)
+
+ Returns:
+ Formatted summary string (not escaped for markdown)
+ """
+ lines = []
+
+ # Buy levels
+ lines.append(f"Buy Levels: {levels.get('num_buy_levels', 0)}")
+ for lvl in levels.get('buy_levels', []):
+ lines.append(f" L{lvl['level']}: {lvl['price']:,.4f} (-{lvl['spread_pct']:.2f}%) ${lvl['amount_quote']:.0f}")
+
+ # Sell levels
+ lines.append(f"Sell Levels: {levels.get('num_sell_levels', 0)}")
+ for lvl in levels.get('sell_levels', []):
+ lines.append(f" L{lvl['level']}: {lvl['price']:,.4f} (+{lvl['spread_pct']:.2f}%) ${lvl['amount_quote']:.0f}")
+
+ lines.append(f"Total Buy: ${levels.get('total_buy_amount', 0):,.2f}")
+ lines.append(f"Total Sell: ${levels.get('total_sell_amount', 0):,.2f}")
+ lines.append(f"Take Profit: {take_profit*100:.3f}%")
+
+ if natr:
+ lines.append(f"NATR (14): {natr*100:.2f}%")
+
+ if levels.get("warnings"):
+ lines.append("Warnings:")
+ for w in levels["warnings"]:
+ lines.append(f" - {w}")
+
+ return "\n".join(lines)
+
+
+def calculate_effective_spread(
+ buy_spreads: List[float],
+ sell_spreads: List[float],
+ buy_amounts_pct: List[float],
+ sell_amounts_pct: List[float],
+) -> Dict[str, float]:
+ """
+ Calculate effective weighted average spreads.
+
+ Args:
+ buy_spreads: List of buy spreads (as decimals)
+ sell_spreads: List of sell spreads (as decimals)
+ buy_amounts_pct: Relative amounts per buy level
+ sell_amounts_pct: Relative amounts per sell level
+
+ Returns:
+ Dict with:
+ - weighted_buy_spread: Amount-weighted average buy spread
+ - weighted_sell_spread: Amount-weighted average sell spread
+ - min_buy_spread: Smallest buy spread
+ - min_sell_spread: Smallest sell spread
+ - max_buy_spread: Largest buy spread
+ - max_sell_spread: Largest sell spread
+ """
+ # Calculate weighted buy spread
+ total_buy_pct = sum(buy_amounts_pct) if buy_amounts_pct else 0
+ if total_buy_pct > 0 and buy_spreads:
+ weighted_buy = sum(s * p for s, p in zip(buy_spreads, buy_amounts_pct)) / total_buy_pct
+ else:
+ weighted_buy = buy_spreads[0] if buy_spreads else 0
+
+ # Calculate weighted sell spread
+ total_sell_pct = sum(sell_amounts_pct) if sell_amounts_pct else 0
+ if total_sell_pct > 0 and sell_spreads:
+ weighted_sell = sum(s * p for s, p in zip(sell_spreads, sell_amounts_pct)) / total_sell_pct
+ else:
+ weighted_sell = sell_spreads[0] if sell_spreads else 0
+
+ return {
+ "weighted_buy_spread": weighted_buy,
+ "weighted_sell_spread": weighted_sell,
+ "min_buy_spread": min(buy_spreads) if buy_spreads else 0,
+ "min_sell_spread": min(sell_spreads) if sell_spreads else 0,
+ "max_buy_spread": max(buy_spreads) if buy_spreads else 0,
+ "max_sell_spread": max(sell_spreads) if sell_spreads else 0,
+ }
diff --git a/handlers/bots/menu.py b/handlers/bots/menu.py
index 02009ba..081f5d7 100644
--- a/handlers/bots/menu.py
+++ b/handlers/bots/menu.py
@@ -45,10 +45,14 @@ def _build_main_menu_keyboard(bots_dict: Dict[str, Any]) -> InlineKeyboardMarkup
InlineKeyboardButton(f"📊 {display_name}", callback_data=f"bots:bot_detail:{bot_name}")
])
- # Action buttons - 3 columns
+ # Action buttons - controller creation
keyboard.append([
InlineKeyboardButton("➕ Grid Strike", callback_data="bots:new_grid_strike"),
- InlineKeyboardButton("🚀 Deploy", callback_data="bots:deploy_menu"),
+ InlineKeyboardButton("➕ PMM Mister", callback_data="bots:new_pmm_mister"),
+ ])
+
+ # Action buttons - configs
+ keyboard.append([
InlineKeyboardButton("📁 Configs", callback_data="bots:controller_configs"),
])
@@ -77,13 +81,14 @@ async def show_bots_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
# Determine if this is a callback query or direct command
query = update.callback_query
msg = update.message or (query.message if query else None)
+ chat_id = update.effective_chat.id
if not msg:
logger.error("No message object available for show_bots_menu")
return
try:
- client = await get_bots_client()
+ client = await get_bots_client(chat_id)
bots_data = await client.bot_orchestration.get_active_bots_status()
# Extract bots dictionary for building keyboard
@@ -115,7 +120,16 @@ async def show_bots_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
reply_markup=reply_markup
)
except BadRequest as e:
- if "Message is not modified" not in str(e):
+ if "no text in the message" in str(e).lower():
+ # Message is a photo/media, delete it and send new text message
+ await query.message.delete()
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=full_message,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ elif "Message is not modified" not in str(e):
raise
else:
await msg.reply_text(
@@ -131,11 +145,23 @@ async def show_bots_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
reply_markup = _build_main_menu_keyboard({})
if query:
- await query.message.edit_text(
- error_message,
- parse_mode="MarkdownV2",
- reply_markup=reply_markup
- )
+ try:
+ await query.message.edit_text(
+ error_message,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ except BadRequest as edit_error:
+ if "no text in the message" in str(edit_error).lower():
+ await query.message.delete()
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=error_message,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ else:
+ raise
else:
await msg.reply_text(
error_message,
@@ -157,6 +183,7 @@ async def show_bot_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, bo
bot_name: Name of the bot to show
"""
query = update.callback_query
+ chat_id = update.effective_chat.id
try:
# Try to get bot info from cached data first
@@ -169,7 +196,7 @@ async def show_bot_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, bo
# If not in cache, fetch fresh data
if not bot_info:
- client = await get_bots_client()
+ client = await get_bots_client(chat_id)
fresh_data = await client.bot_orchestration.get_active_bots_status()
if isinstance(fresh_data, dict) and "data" in fresh_data:
bot_info = fresh_data.get("data", {}).get(bot_name)
@@ -197,80 +224,135 @@ async def show_bot_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, bo
f"{status_emoji} `{escape_markdown_v2(display_name)}`",
]
- # Controllers and performance - table format
+ # Controllers and performance - rich format
performance = bot_info.get("performance", {})
controller_names = list(performance.keys())
# Store controller list for index-based callbacks
context.user_data["current_controllers"] = controller_names
+ # Build keyboard with controller rows
+ keyboard = []
+
if performance:
total_pnl = 0
total_volume = 0
total_realized = 0
total_unrealized = 0
- # Create table with header (same format as dashboard)
- lines.append("")
- lines.append("```")
- lines.append(f"{'Controller':<28} {'PnL':>8} {'Vol':>7}")
- lines.append(f"{'─'*28} {'─'*8} {'─'*7}")
-
for idx, (ctrl_name, ctrl_info) in enumerate(performance.items()):
- if isinstance(ctrl_info, dict):
- ctrl_status = ctrl_info.get("status", "unknown")
- ctrl_perf = ctrl_info.get("performance", {})
-
- realized = ctrl_perf.get("realized_pnl_quote", 0) or 0
- unrealized = ctrl_perf.get("unrealized_pnl_quote", 0) or 0
- volume = ctrl_perf.get("volume_traded", 0) or 0
- pnl = realized + unrealized
-
- total_pnl += pnl
- total_volume += volume
- total_realized += realized
- total_unrealized += unrealized
-
- # Status prefix + full controller name (truncate to 27 chars)
- status_prefix = "▶" if ctrl_status == "running" else "⏸"
- ctrl_display = f"{status_prefix}{ctrl_name}"[:27]
-
- # Format numbers compactly
- pnl_str = f"{pnl:+.2f}"[:8]
- vol_str = f"{volume/1000:.1f}k" if volume >= 1000 else f"{volume:.0f}"
- vol_str = vol_str[:7]
-
- lines.append(f"{ctrl_display:<28} {pnl_str:>8} {vol_str:>7}")
-
- # Totals row
- lines.append(f"{'─'*28} {'─'*8} {'─'*7}")
- vol_total = f"{total_volume/1000:.1f}k" if total_volume >= 1000 else f"{total_volume:.0f}"
- pnl_total_str = f"{total_pnl:+.2f}"[:8]
- lines.append(f"{'TOTAL':<28} {pnl_total_str:>8} {vol_total:>7}")
- lines.append("```")
-
- # Add PnL breakdown
- pnl_emoji = "📈" if total_pnl >= 0 else "📉"
- lines.append(f"\n{pnl_emoji} Realized: `{escape_markdown_v2(f'{total_realized:+.2f}')}` \\| Unrealized: `{escape_markdown_v2(f'{total_unrealized:+.2f}')}`")
-
- # Error summary
+ if not isinstance(ctrl_info, dict):
+ continue
+
+ ctrl_status = ctrl_info.get("status", "unknown")
+ ctrl_perf = ctrl_info.get("performance", {})
+
+ realized = ctrl_perf.get("realized_pnl_quote", 0) or 0
+ unrealized = ctrl_perf.get("unrealized_pnl_quote", 0) or 0
+ volume = ctrl_perf.get("volume_traded", 0) or 0
+ pnl = realized + unrealized
+
+ total_pnl += pnl
+ total_volume += volume
+ total_realized += realized
+ total_unrealized += unrealized
+
+ # Controller section - compact format
+ lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
+
+ # Controller name and status
+ ctrl_status_emoji = "▶️" if ctrl_status == "running" else "⏸️"
+ lines.append(f"{ctrl_status_emoji} *{escape_markdown_v2(ctrl_name)}*")
+
+ # P&L + Volume in one line (compact)
+ pnl_emoji = "🟢" if pnl >= 0 else "🔴"
+ vol_str = f"{volume/1000:.1f}k" if volume >= 1000 else f"{volume:.0f}"
+ lines.append(f"{pnl_emoji} pnl: `{escape_markdown_v2(f'{pnl:+.2f}')}` \\(R: `{escape_markdown_v2(f'{realized:+.2f}')}` / U: `{escape_markdown_v2(f'{unrealized:+.2f}')}`\\) 📦 vol: `{escape_markdown_v2(vol_str)}`")
+
+ # Open Positions section
+ positions = ctrl_perf.get("positions_summary", [])
+ if positions:
+ lines.append("")
+ lines.append(f"*Open Positions* \\({len(positions)}\\)")
+ # Extract trading pair from controller name for display
+ trading_pair = _extract_pair_from_name(ctrl_name)
+ for pos in positions:
+ side_raw = pos.get("side", "")
+ is_long = "BUY" in str(side_raw).upper()
+ side_emoji = "🟢" if is_long else "🔴"
+ side_str = "L" if is_long else "S"
+ amount = pos.get("amount", 0) or 0
+ breakeven = pos.get("breakeven_price", 0) or 0
+ pos_value = amount * breakeven
+ pos_unrealized = pos.get("unrealized_pnl_quote", 0) or 0
+
+ lines.append(f"📍 {escape_markdown_v2(trading_pair)} {side_emoji}{side_str} `${escape_markdown_v2(f'{pos_value:.2f}')}` @ `{escape_markdown_v2(f'{breakeven:.4f}')}` \\| U: `{escape_markdown_v2(f'{pos_unrealized:+.2f}')}`")
+
+ # Closed Positions section
+ close_counts = ctrl_perf.get("close_type_counts", {})
+ if close_counts:
+ total_closed = sum(close_counts.values())
+ lines.append("")
+ lines.append(f"*Closed Positions* \\({total_closed}\\)")
+
+ # Extract counts for each type
+ tp = _get_close_count(close_counts, "TAKE_PROFIT")
+ sl = _get_close_count(close_counts, "STOP_LOSS")
+ hold = _get_close_count(close_counts, "POSITION_HOLD")
+ early = _get_close_count(close_counts, "EARLY_STOP")
+ insuf = _get_close_count(close_counts, "INSUFFICIENT_BALANCE")
+
+ # Row 1: TP | SL (if any)
+ row1_parts = []
+ if tp > 0:
+ row1_parts.append(f"🎯 TP: `{tp}`")
+ if sl > 0:
+ row1_parts.append(f"🛑 SL: `{sl}`")
+ if row1_parts:
+ lines.append(" \\| ".join(row1_parts))
+
+ # Row 2: Hold | Early (if any)
+ row2_parts = []
+ if hold > 0:
+ row2_parts.append(f"✋ Hold: `{hold}`")
+ if early > 0:
+ row2_parts.append(f"⚡ Early: `{early}`")
+ if row2_parts:
+ lines.append(" \\| ".join(row2_parts))
+
+ # Row 3: Insufficient balance (if any)
+ if insuf > 0:
+ lines.append(f"⚠️ Insuf\\. Balance: `{insuf}`")
+
+ # Add controller button row: [✏️ controller_name] [▶️/⏸️]
+ toggle_emoji = "⏸" if ctrl_status == "running" else "▶️"
+ toggle_action = "stop_ctrl_quick" if ctrl_status == "running" else "start_ctrl_quick"
+
+ if idx < 8: # Max 8 controllers with buttons
+ # Use shortened name for button but keep it readable
+ btn_name = _shorten_controller_name(ctrl_name, 22)
+ keyboard.append([
+ InlineKeyboardButton(f"✏️ {btn_name}", callback_data=f"bots:ctrl_idx:{idx}"),
+ InlineKeyboardButton(toggle_emoji, callback_data=f"bots:{toggle_action}:{idx}"),
+ ])
+
+ # Total summary (only if multiple controllers)
+ if len(performance) > 1:
+ lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
+ pnl_emoji = "🟢" if total_pnl >= 0 else "🔴"
+ vol_total = f"{total_volume/1000:.1f}k" if total_volume >= 1000 else f"{total_volume:.0f}"
+ lines.append(f"*TOTAL* {pnl_emoji} pnl: `{escape_markdown_v2(f'{total_pnl:+.2f}')}` \\(R: `{escape_markdown_v2(f'{total_realized:+.2f}')}` / U: `{escape_markdown_v2(f'{total_unrealized:+.2f}')}`\\) 📦 vol: `{escape_markdown_v2(vol_total)}`")
+
+ # Error summary at the bottom
error_logs = bot_info.get("error_logs", [])
if error_logs:
- lines.append(f"\n⚠️ *{len(error_logs)} error\\(s\\)*")
-
- # Build keyboard - numbered buttons for controllers (4 per row)
- keyboard = []
-
- # Controller buttons - up to 8 in 4-column layout
- ctrl_buttons = []
- for idx in range(min(len(controller_names), 8)):
- ctrl_buttons.append(
- InlineKeyboardButton(f"⚙️{idx+1}", callback_data=f"bots:ctrl_idx:{idx}")
- )
-
- # Add controller buttons in rows of 4
- for i in range(0, len(ctrl_buttons), 4):
- keyboard.append(ctrl_buttons[i:i+4])
+ lines.append("")
+ lines.append(f"⚠️ *{len(error_logs)} error\\(s\\):*")
+ # Show last 2 errors briefly
+ for err in error_logs[-2:]:
+ err_msg = err.get("msg", str(err)) if isinstance(err, dict) else str(err)
+ err_short = err_msg[:60] + "..." if len(err_msg) > 60 else err_msg
+ lines.append(f" `{escape_markdown_v2(err_short)}`")
# Bot-level actions
keyboard.append([
@@ -286,11 +368,24 @@ async def show_bot_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, bo
reply_markup = InlineKeyboardMarkup(keyboard)
try:
- await query.message.edit_text(
- "\n".join(lines),
- parse_mode="MarkdownV2",
- reply_markup=reply_markup
- )
+ # Check if current message is a photo (from controller detail view)
+ if getattr(query.message, 'photo', None):
+ # Delete photo message and send new text message
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ await query.message.chat.send_message(
+ "\n".join(lines),
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ else:
+ await query.message.edit_text(
+ "\n".join(lines),
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
except BadRequest as e:
if "Message is not modified" in str(e):
# Message content is the same, just answer the callback
@@ -302,11 +397,57 @@ async def show_bot_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, bo
logger.error(f"Error showing bot detail: {e}", exc_info=True)
error_message = format_error_message(f"Failed to fetch bot status: {str(e)}")
keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]]
- await query.message.edit_text(
- error_message,
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
+ try:
+ if getattr(query.message, 'photo', None):
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ await query.message.chat.send_message(
+ error_message,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ else:
+ await query.message.edit_text(
+ error_message,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ except Exception:
+ pass
+
+
+def _extract_pair_from_name(ctrl_name: str) -> str:
+ """Extract trading pair from controller name
+
+ Example: "007_gs_binance_SOL-FDUSD" -> "SOL-FDUSD"
+ """
+ parts = ctrl_name.split("_")
+ for part in parts:
+ if "-" in part and part.upper() == part:
+ return part
+ # Fallback: return last part with dash or truncated name
+ for part in reversed(parts):
+ if "-" in part:
+ return part
+ return ctrl_name[:20]
+
+
+def _get_close_count(close_counts: dict, type_suffix: str) -> int:
+ """Get count for a close type, handling the CloseType. prefix
+
+ Args:
+ close_counts: Dict of close type -> count
+ type_suffix: Type name without prefix (e.g., "TAKE_PROFIT")
+
+ Returns:
+ Count for that type, or 0 if not found
+ """
+ for key, count in close_counts.items():
+ if key.endswith(type_suffix):
+ return count
+ return 0
def _shorten_controller_name(name: str, max_len: int = 28) -> str:
@@ -366,8 +507,9 @@ def _shorten_controller_name(name: str, max_len: int = 28) -> str:
# ============================================
async def show_controller_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, controller_idx: int) -> None:
- """Show controller detail with edit/stop options (using index)"""
+ """Show controller detail with editable config (like networks.py pattern)"""
query = update.callback_query
+ chat_id = update.effective_chat.id
bot_name = context.user_data.get("current_bot_name")
bot_info = context.user_data.get("current_bot_info", {})
@@ -397,18 +539,12 @@ async def show_controller_detail(update: Update, context: ContextTypes.DEFAULT_T
volume = ctrl_perf.get("volume_traded", 0) or 0
pnl = realized + unrealized
- pnl_emoji = "📈" if pnl >= 0 else "📉"
- status_emoji = "🟢" if ctrl_status == "running" else "🔴"
-
- short_name = _shorten_controller_name(controller_name, 35)
-
- # Try to fetch controller config for additional info
+ # Try to fetch controller config
ctrl_config = None
is_grid_strike = False
- chart_bytes = None
try:
- client = await get_bots_client()
+ client = await get_bots_client(chat_id)
configs = await client.controllers.get_bot_controller_configs(bot_name)
# Find the matching config
@@ -418,84 +554,53 @@ async def show_controller_detail(update: Update, context: ContextTypes.DEFAULT_T
break
if ctrl_config:
- # Store for later use
context.user_data["current_controller_config"] = ctrl_config
controller_type = ctrl_config.get("controller_name", "")
is_grid_strike = "grid_strike" in controller_type.lower()
- # For grid strike, generate chart
- if is_grid_strike:
- try:
- connector = ctrl_config.get("connector_name", "")
- pair = ctrl_config.get("trading_pair", "")
-
- # Fetch candles and current price
- candles = await client.market_data.get_candles(
- connector_name=connector,
- trading_pair=pair,
- interval="1h",
- max_records=100
- )
- prices = await client.market_data.get_prices(
- connector_name=connector,
- trading_pairs=pair
- )
- current_price = prices.get("prices", {}).get(pair)
-
- # Generate chart
- from .controllers.grid_strike import generate_chart
- chart_bytes = generate_chart(ctrl_config, candles, current_price)
- except Exception as chart_err:
- logger.warning(f"Could not generate chart: {chart_err}")
-
except Exception as e:
logger.warning(f"Could not fetch controller config: {e}")
- # Build caption (shorter for photo message, max 1024 chars)
- # Format PnL values with escaping
- pnl_str = escape_markdown_v2(f"{pnl:+.2f}")
- realized_str = escape_markdown_v2(f"{realized:+.2f}")
- unrealized_str = escape_markdown_v2(f"{unrealized:+.2f}")
- vol_str = escape_markdown_v2(format_number(volume))
+ # Build message with P&L summary + editable config
+ status_emoji = "▶️" if ctrl_status == "running" else "⏸️"
+ pnl_emoji = "🟢" if pnl >= 0 else "🔴"
+ vol_str = f"{volume/1000:.1f}k" if volume >= 1000 else f"{volume:.0f}"
- if is_grid_strike and ctrl_config:
- connector = ctrl_config.get("connector_name", "N/A")
- pair = ctrl_config.get("trading_pair", "N/A")
- side_val = ctrl_config.get("side", 1)
- side_str = "LONG" if side_val == 1 else "SHORT"
- leverage = ctrl_config.get("leverage", 1)
- start_p = ctrl_config.get("start_price", 0)
- end_p = ctrl_config.get("end_price", 0)
- limit_p = ctrl_config.get("limit_price", 0)
- total_amt = ctrl_config.get("total_amount_quote", 0)
-
- caption_lines = [
- f"{status_emoji} *{escape_markdown_v2(pair)}* \\| {escape_markdown_v2(side_str)} {leverage}x",
- f"{pnl_emoji} PnL: `{pnl_str}` \\(R: {realized_str} U: {unrealized_str}\\)",
- f"📊 Vol: `{vol_str}`",
- "",
- f"Grid: `{escape_markdown_v2(f'{start_p:.6g}')}` → `{escape_markdown_v2(f'{end_p:.6g}')}`",
- f"Limit: `{escape_markdown_v2(f'{limit_p:.6g}')}` \\| Amt: `{total_amt}`",
- ]
- caption = "\n".join(caption_lines)
- else:
- caption_lines = [
- f"⚙️ `{escape_markdown_v2(short_name)}`",
- f"{status_emoji} Status: `{escape_markdown_v2(ctrl_status)}`",
- "",
- f"{pnl_emoji} *PnL:* `{pnl_str}` \\| 📊 *Vol:* `{vol_str}`",
- f" Realized: `{realized_str}`",
- f" Unrealized: `{unrealized_str}`",
- ]
- caption = "\n".join(caption_lines)
+ lines = [
+ f"{status_emoji} *{escape_markdown_v2(controller_name)}*",
+ "",
+ f"{pnl_emoji} `{escape_markdown_v2(f'{pnl:+.2f}')}` \\| 💰 R: `{escape_markdown_v2(f'{realized:+.2f}')}` \\| 📊 U: `{escape_markdown_v2(f'{unrealized:+.2f}')}`",
+ f"📦 Vol: `{escape_markdown_v2(vol_str)}`",
+ ]
+
+ # Add editable config section if available
+ if ctrl_config and is_grid_strike:
+ editable_fields = _get_editable_controller_fields(ctrl_config)
+
+ # Store for input processing
+ context.user_data["ctrl_editable_fields"] = editable_fields
+ context.user_data["bots_state"] = "ctrl_bulk_edit"
+ context.user_data["ctrl_edit_chat_id"] = query.message.chat_id
+
+ # Build config text
+ config_lines = []
+ for key, value in editable_fields.items():
+ config_lines.append(f"{key}={value}")
+ config_text = "\n".join(config_lines)
+
+ lines.append("")
+ lines.append("```")
+ lines.append(config_text)
+ lines.append("```")
+ lines.append("")
+ lines.append("✏️ _Send `key=value` to update_")
# Build keyboard
keyboard = []
- # For grid strike, add edit options
if is_grid_strike and ctrl_config:
keyboard.append([
- InlineKeyboardButton("✏️ Edit Config", callback_data="bots:ctrl_edit"),
+ InlineKeyboardButton("📊 Chart", callback_data="bots:ctrl_chart"),
InlineKeyboardButton("🛑 Stop", callback_data="bots:stop_ctrl"),
])
else:
@@ -505,37 +610,31 @@ async def show_controller_detail(update: Update, context: ContextTypes.DEFAULT_T
keyboard.append([
InlineKeyboardButton("⬅️ Back", callback_data="bots:back_to_bot"),
- InlineKeyboardButton("🔄 Refresh", callback_data=f"bots:ctrl_idx:{controller_idx}"),
+ InlineKeyboardButton("🔄 Refresh", callback_data=f"bots:refresh_ctrl:{controller_idx}"),
])
reply_markup = InlineKeyboardMarkup(keyboard)
+ text_content = "\n".join(lines)
- # Send as photo if we have a chart, otherwise text
- if chart_bytes:
- # Delete old message and send new photo message
+ # Store message_id for later edits
+ if getattr(query.message, 'photo', None):
try:
await query.message.delete()
except Exception:
pass
-
- await query.message.chat.send_photo(
- photo=chart_bytes,
- caption=caption,
+ sent_msg = await query.message.chat.send_message(
+ text_content,
parse_mode="MarkdownV2",
reply_markup=reply_markup
)
+ context.user_data["ctrl_edit_message_id"] = sent_msg.message_id
else:
- # Fallback to text message
- full_lines = [
- "*Controller Details*",
- "",
- ] + caption_lines
-
await query.message.edit_text(
- "\n".join(full_lines),
+ text_content,
parse_mode="MarkdownV2",
reply_markup=reply_markup
)
+ context.user_data["ctrl_edit_message_id"] = query.message.message_id
async def handle_stop_controller(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -560,18 +659,35 @@ async def handle_stop_controller(update: Update, context: ContextTypes.DEFAULT_T
],
]
- await query.message.edit_text(
+ message_text = (
f"*Stop Controller?*\n\n"
f"`{escape_markdown_v2(short_name)}`\n\n"
- f"This will stop the controller\\.",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
+ f"This will stop the controller\\."
)
+ # Handle photo messages (from controller detail view with chart)
+ if getattr(query.message, 'photo', None):
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ await query.message.chat.send_message(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ else:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
async def handle_confirm_stop_controller(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Actually stop the controller"""
query = update.callback_query
+ chat_id = update.effective_chat.id
bot_name = context.user_data.get("current_bot_name")
controllers = context.user_data.get("current_controllers", [])
@@ -590,7 +706,7 @@ async def handle_confirm_stop_controller(update: Update, context: ContextTypes.D
)
try:
- client = await get_bots_client()
+ client = await get_bots_client(chat_id)
# Stop controller by setting manual_kill_switch=True
result = await client.controllers.update_bot_controller_config(
@@ -617,21 +733,177 @@ async def handle_confirm_stop_controller(update: Update, context: ContextTypes.D
)
+async def handle_quick_stop_controller(update: Update, context: ContextTypes.DEFAULT_TYPE, controller_idx: int) -> None:
+ """Quick stop controller from bot detail view (no confirmation)"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ bot_name = context.user_data.get("current_bot_name")
+ controllers = context.user_data.get("current_controllers", [])
+
+ if not bot_name or controller_idx >= len(controllers):
+ await query.answer("Context lost", show_alert=True)
+ return
+
+ controller_name = controllers[controller_idx]
+ short_name = _shorten_controller_name(controller_name, 20)
+
+ await query.answer(f"Stopping {short_name}...")
+
+ try:
+ client = await get_bots_client(chat_id)
+
+ # Stop controller by setting manual_kill_switch=True
+ await client.controllers.update_bot_controller_config(
+ bot_name=bot_name,
+ controller_name=controller_name,
+ config={"manual_kill_switch": True}
+ )
+
+ # Refresh bot detail view
+ context.user_data.pop("current_bot_info", None)
+ await show_bot_detail(update, context, bot_name)
+
+ except Exception as e:
+ logger.error(f"Error stopping controller: {e}", exc_info=True)
+ await query.answer(f"Failed: {str(e)[:50]}", show_alert=True)
+
+
+async def handle_quick_start_controller(update: Update, context: ContextTypes.DEFAULT_TYPE, controller_idx: int) -> None:
+ """Quick start/resume controller from bot detail view"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ bot_name = context.user_data.get("current_bot_name")
+ controllers = context.user_data.get("current_controllers", [])
+
+ if not bot_name or controller_idx >= len(controllers):
+ await query.answer("Context lost", show_alert=True)
+ return
+
+ controller_name = controllers[controller_idx]
+ short_name = _shorten_controller_name(controller_name, 20)
+
+ await query.answer(f"Starting {short_name}...")
+
+ try:
+ client = await get_bots_client(chat_id)
+
+ # Start controller by setting manual_kill_switch=False
+ await client.controllers.update_bot_controller_config(
+ bot_name=bot_name,
+ controller_name=controller_name,
+ config={"manual_kill_switch": False}
+ )
+
+ # Refresh bot detail view
+ context.user_data.pop("current_bot_info", None)
+ await show_bot_detail(update, context, bot_name)
+
+ except Exception as e:
+ logger.error(f"Error starting controller: {e}", exc_info=True)
+ await query.answer(f"Failed: {str(e)[:50]}", show_alert=True)
+
+
# ============================================
# CONTROLLER CHART & EDIT
# ============================================
async def show_controller_chart(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Refresh and show OHLC chart for grid strike controller"""
+ """Generate and show OHLC chart for grid strike controller"""
query = update.callback_query
+ chat_id = update.effective_chat.id
controller_idx = context.user_data.get("current_controller_idx", 0)
+ ctrl_config = context.user_data.get("current_controller_config")
- # Just call the detail view which now shows the chart
- await show_controller_detail(update, context, controller_idx)
+ if not ctrl_config:
+ await query.answer("Config not found", show_alert=True)
+ return
+
+ # Show loading message
+ short_name = _shorten_controller_name(ctrl_config.get("id", ""), 30)
+ loading_text = f"⏳ *Generating chart\\.\\.\\.*"
+
+ try:
+ await query.message.edit_text(loading_text, parse_mode="MarkdownV2")
+ except Exception:
+ pass
+
+ try:
+ client = await get_bots_client(chat_id)
+ connector = ctrl_config.get("connector_name", "")
+ pair = ctrl_config.get("trading_pair", "")
+
+ # Fetch candles and current price
+ candles = await client.market_data.get_candles(
+ connector_name=connector,
+ trading_pair=pair,
+ interval="1h",
+ max_records=420
+ )
+ prices = await client.market_data.get_prices(
+ connector_name=connector,
+ trading_pairs=pair
+ )
+ current_price = prices.get("prices", {}).get(pair)
+
+ # Generate chart
+ from .controllers.grid_strike import generate_chart
+ chart_bytes = generate_chart(ctrl_config, candles, current_price)
+
+ if chart_bytes:
+ # Build caption
+ side_val = ctrl_config.get("side", 1)
+ side_str = "LONG" if side_val == 1 else "SHORT"
+ leverage = ctrl_config.get("leverage", 1)
+ start_p = ctrl_config.get("start_price", 0)
+ end_p = ctrl_config.get("end_price", 0)
+ limit_p = ctrl_config.get("limit_price", 0)
+
+ caption = (
+ f"📊 *{escape_markdown_v2(pair)}* \\| {escape_markdown_v2(side_str)} {leverage}x\n"
+ f"Grid: `{escape_markdown_v2(f'{start_p:.6g}')}` → `{escape_markdown_v2(f'{end_p:.6g}')}`\n"
+ f"Limit: `{escape_markdown_v2(f'{limit_p:.6g}')}`"
+ )
+
+ keyboard = [[
+ InlineKeyboardButton("⬅️ Back", callback_data=f"bots:ctrl_idx:{controller_idx}"),
+ InlineKeyboardButton("🔄 Refresh", callback_data="bots:ctrl_chart"),
+ ]]
+
+ # Delete text message and send photo
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ await query.message.chat.send_photo(
+ photo=chart_bytes,
+ caption=caption,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ else:
+ await query.message.edit_text(
+ "❌ Could not generate chart",
+ reply_markup=InlineKeyboardMarkup([[
+ InlineKeyboardButton("⬅️ Back", callback_data=f"bots:ctrl_idx:{controller_idx}")
+ ]])
+ )
+
+ except Exception as e:
+ logger.error(f"Error generating chart: {e}", exc_info=True)
+ await query.message.edit_text(
+ f"❌ Error: {escape_markdown_v2(str(e)[:100])}",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup([[
+ InlineKeyboardButton("⬅️ Back", callback_data=f"bots:ctrl_idx:{controller_idx}")
+ ]])
+ )
async def show_controller_edit(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Show editable parameters for grid strike controller"""
+ """Show editable parameters for grid strike controller in bulk edit format"""
query = update.callback_query
bot_name = context.user_data.get("current_bot_name")
@@ -643,79 +915,78 @@ async def show_controller_edit(update: Update, context: ContextTypes.DEFAULT_TYP
return
controller_name = ctrl_config.get("id", "")
- short_name = _shorten_controller_name(controller_name, 30)
- # Editable parameters for live controller
+ # Define editable fields with their current values
+ editable_fields = _get_editable_controller_fields(ctrl_config)
+
+ # Store editable fields in context for input processing
+ context.user_data["ctrl_editable_fields"] = editable_fields
+ context.user_data["bots_state"] = "ctrl_bulk_edit"
+ context.user_data["ctrl_edit_message_id"] = query.message.message_id if not getattr(query.message, 'photo', None) else None
+ context.user_data["ctrl_edit_chat_id"] = query.message.chat_id
+
+ # Build config text for display
+ config_lines = []
+ for key, value in editable_fields.items():
+ config_lines.append(f"{key}={value}")
+ config_text = "\n".join(config_lines)
+
lines = [
- "*Edit Controller Config*",
+ f"✏️ *Edit Controller*",
"",
- f"⚙️ `{escape_markdown_v2(short_name)}`",
+ f"`{escape_markdown_v2(controller_name)}`",
"",
- "_Select a parameter to modify:_",
+ f"```",
+ f"{config_text}",
+ f"```",
"",
+ "_Send only the fields you want to change\\._",
+ "_Format: `key=value` \\(one per line\\)_",
]
- # Show current values
- lines.append("```")
+ keyboard = [
+ [InlineKeyboardButton("❌ Cancel", callback_data=f"bots:ctrl_idx:{controller_idx}")],
+ ]
- # Price params (can adjust grid)
- start_p = ctrl_config.get("start_price", 0)
- end_p = ctrl_config.get("end_price", 0)
- limit_p = ctrl_config.get("limit_price", 0)
- lines.append(f"{'start_price:':<18} {start_p:.6g}")
- lines.append(f"{'end_price:':<18} {end_p:.6g}")
- lines.append(f"{'limit_price:':<18} {limit_p:.6g}")
- lines.append("")
+ reply_markup = InlineKeyboardMarkup(keyboard)
+ text_content = "\n".join(lines)
+
+ # Handle photo messages (from controller detail view with chart)
+ if getattr(query.message, 'photo', None):
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ sent_msg = await query.message.chat.send_message(
+ text_content,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ context.user_data["ctrl_edit_message_id"] = sent_msg.message_id
+ else:
+ await query.message.edit_text(
+ text_content,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ context.user_data["ctrl_edit_message_id"] = query.message.message_id
- # Trading params
- total_amt = ctrl_config.get("total_amount_quote", 0)
- max_orders = ctrl_config.get("max_open_orders", 3)
- max_batch = ctrl_config.get("max_orders_per_batch", 1)
- min_spread = ctrl_config.get("min_spread_between_orders", 0.0002)
+def _get_editable_controller_fields(ctrl_config: Dict[str, Any]) -> Dict[str, Any]:
+ """Extract editable fields from controller config"""
tp_cfg = ctrl_config.get("triple_barrier_config", {})
take_profit = tp_cfg.get("take_profit", 0.0001) if isinstance(tp_cfg, dict) else 0.0001
- lines.append(f"{'total_amount_quote:':<18} {total_amt}")
- lines.append(f"{'max_open_orders:':<18} {max_orders}")
- lines.append(f"{'max_orders_per_batch:':<18} {max_batch}")
- lines.append(f"{'min_spread:':<18} {min_spread:.4%}")
- lines.append(f"{'take_profit:':<18} {take_profit:.4%}")
- lines.append("```")
-
- # Build keyboard with edit buttons (grouped by type)
- keyboard = [
- # Price adjustments
- [
- InlineKeyboardButton("📍 Start", callback_data="bots:ctrl_set:start_price"),
- InlineKeyboardButton("📍 End", callback_data="bots:ctrl_set:end_price"),
- InlineKeyboardButton("🛡️ Limit", callback_data="bots:ctrl_set:limit_price"),
- ],
- # Trading params
- [
- InlineKeyboardButton("💰 Amount", callback_data="bots:ctrl_set:total_amount_quote"),
- InlineKeyboardButton("📊 Max Orders", callback_data="bots:ctrl_set:max_open_orders"),
- ],
- [
- InlineKeyboardButton("🎯 Take Profit", callback_data="bots:ctrl_set:take_profit"),
- InlineKeyboardButton("📏 Min Spread", callback_data="bots:ctrl_set:min_spread_between_orders"),
- ],
- # Toggle manual kill switch (stop/start)
- [
- InlineKeyboardButton("⏸️ Pause Controller", callback_data="bots:ctrl_set:manual_kill_switch"),
- ],
- [
- InlineKeyboardButton("⬅️ Back", callback_data=f"bots:ctrl_idx:{controller_idx}"),
- ],
- ]
-
- reply_markup = InlineKeyboardMarkup(keyboard)
-
- await query.message.edit_text(
- "\n".join(lines),
- parse_mode="MarkdownV2",
- reply_markup=reply_markup
- )
+ return {
+ "start_price": ctrl_config.get("start_price", 0),
+ "end_price": ctrl_config.get("end_price", 0),
+ "limit_price": ctrl_config.get("limit_price", 0),
+ "total_amount_quote": ctrl_config.get("total_amount_quote", 0),
+ "max_open_orders": ctrl_config.get("max_open_orders", 3),
+ "max_orders_per_batch": ctrl_config.get("max_orders_per_batch", 1),
+ "min_spread_between_orders": ctrl_config.get("min_spread_between_orders", 0.0001),
+ "take_profit": take_profit,
+ }
async def handle_controller_set_field(update: Update, context: ContextTypes.DEFAULT_TYPE, field_name: str) -> None:
@@ -790,6 +1061,7 @@ async def handle_controller_set_field(update: Update, context: ContextTypes.DEFA
async def handle_controller_confirm_set(update: Update, context: ContextTypes.DEFAULT_TYPE, field_name: str, value: str) -> None:
"""Confirm and apply a controller field change"""
query = update.callback_query
+ chat_id = update.effective_chat.id
bot_name = context.user_data.get("current_bot_name")
ctrl_config = context.user_data.get("current_controller_config")
@@ -815,7 +1087,7 @@ async def handle_controller_confirm_set(update: Update, context: ContextTypes.DE
await query.answer("Updating...")
try:
- client = await get_bots_client()
+ client = await get_bots_client(chat_id)
# Build config update
if field_name == "take_profit":
@@ -892,46 +1164,98 @@ async def handle_controller_confirm_set(update: Update, context: ContextTypes.DE
async def process_controller_field_input(update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str) -> None:
- """Process user input for controller field editing"""
- field_name = context.user_data.get("editing_ctrl_field")
+ """Process user input for controller bulk edit - parses key=value lines"""
+ chat_id = update.effective_chat.id
bot_name = context.user_data.get("current_bot_name")
ctrl_config = context.user_data.get("current_controller_config")
controllers = context.user_data.get("current_controllers", [])
controller_idx = context.user_data.get("current_controller_idx")
+ editable_fields = context.user_data.get("ctrl_editable_fields", {})
+ message_id = context.user_data.get("ctrl_edit_message_id")
- if not field_name or not bot_name or not ctrl_config or controller_idx is None:
+ if not bot_name or not ctrl_config or controller_idx is None:
await update.message.reply_text("Context lost. Please start over.")
return
controller_name = controllers[controller_idx]
- # Clear state
- context.user_data.pop("bots_state", None)
- context.user_data.pop("editing_ctrl_field", None)
-
- # Parse value
+ # Delete user's input message for clean chat
try:
- parsed_value = float(user_input)
- except ValueError:
- await update.message.reply_text(
- f"❌ Invalid value\\. Please enter a number\\.",
- parse_mode="MarkdownV2"
+ await update.message.delete()
+ except Exception:
+ pass
+
+ # Parse key=value lines
+ updates = {}
+ errors = []
+
+ for line in user_input.split('\n'):
+ line = line.strip()
+ if not line or '=' not in line:
+ continue
+
+ key, _, value = line.partition('=')
+ key = key.strip()
+ value = value.strip()
+
+ # Validate key exists in editable fields
+ if key not in editable_fields:
+ errors.append(f"Unknown: {key}")
+ continue
+
+ # Convert value to appropriate type
+ current_val = editable_fields.get(key)
+ try:
+ if isinstance(current_val, bool):
+ parsed_value = value.lower() in ['true', '1', 'yes', 'y', 'on']
+ elif isinstance(current_val, int):
+ parsed_value = int(value)
+ elif isinstance(current_val, float):
+ parsed_value = float(value)
+ else:
+ parsed_value = value
+ updates[key] = parsed_value
+ except ValueError:
+ errors.append(f"Invalid: {key}={value}")
+
+ if errors:
+ error_msg = "⚠️ " + ", ".join(errors)
+ await update.get_bot().send_message(chat_id=chat_id, text=error_msg)
+
+ if not updates:
+ await update.get_bot().send_message(
+ chat_id=chat_id,
+ text="❌ No valid updates found. Use format: key=value"
)
return
- # Validate and update
- try:
- client = await get_bots_client()
+ # Clear state
+ context.user_data.pop("bots_state", None)
+ context.user_data.pop("ctrl_editable_fields", None)
- # Build config update
- if field_name == "take_profit":
- update_config = {
- "triple_barrier_config": {
- "take_profit": parsed_value
- }
- }
+ # Show saving message
+ saving_text = f"💾 Saving configuration\\.\\.\\."
+ try:
+ if message_id:
+ await update.get_bot().edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text=saving_text,
+ parse_mode="MarkdownV2"
+ )
+ except Exception:
+ pass
+
+ # Build config update - handle take_profit specially
+ update_config = {}
+ for key, value in updates.items():
+ if key == "take_profit":
+ update_config["triple_barrier_config"] = {"take_profit": value}
else:
- update_config = {field_name: parsed_value}
+ update_config[key] = value
+
+ try:
+ client = await get_bots_client(chat_id)
# Apply the update
result = await client.controllers.update_bot_controller_config(
@@ -942,36 +1266,68 @@ async def process_controller_field_input(update: Update, context: ContextTypes.D
if result.get("status") == "success":
# Update local config cache
- if field_name == "take_profit":
- if "triple_barrier_config" not in ctrl_config:
- ctrl_config["triple_barrier_config"] = {}
- ctrl_config["triple_barrier_config"]["take_profit"] = parsed_value
- else:
- ctrl_config[field_name] = parsed_value
+ for key, value in updates.items():
+ if key == "take_profit":
+ if "triple_barrier_config" not in ctrl_config:
+ ctrl_config["triple_barrier_config"] = {}
+ ctrl_config["triple_barrier_config"]["take_profit"] = value
+ else:
+ ctrl_config[key] = value
context.user_data["current_controller_config"] = ctrl_config
+ # Format updated fields
+ updated_lines = [f"`{escape_markdown_v2(k)}` \\= `{escape_markdown_v2(str(v))}`" for k, v in updates.items()]
+
keyboard = [[
- InlineKeyboardButton("⬅️ Back to Edit", callback_data="bots:ctrl_edit"),
InlineKeyboardButton("⬅️ Controller", callback_data=f"bots:ctrl_idx:{controller_idx}"),
+ InlineKeyboardButton("⬅️ Bot", callback_data="bots:back_to_bot"),
]]
- await update.message.reply_text(
- f"✅ *Updated*\n\n`{escape_markdown_v2(field_name)}` \\= `{escape_markdown_v2(str(parsed_value))}`",
- parse_mode="MarkdownV2",
- reply_markup=InlineKeyboardMarkup(keyboard)
- )
+ success_text = f"✅ *Configuration Updated*\n\n" + "\n".join(updated_lines)
+
+ if message_id:
+ await update.get_bot().edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text=success_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ else:
+ await update.get_bot().send_message(
+ chat_id=chat_id,
+ text=success_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
else:
error_msg = result.get("message", "Update failed")
- await update.message.reply_text(
- f"❌ *Update Failed*\n\n{escape_markdown_v2(str(error_msg)[:200])}",
- parse_mode="MarkdownV2"
- )
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data=f"bots:ctrl_idx:{controller_idx}")]]
+
+ if message_id:
+ await update.get_bot().edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text=f"❌ *Update Failed*\n\n{escape_markdown_v2(str(error_msg)[:200])}",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+ else:
+ await update.get_bot().send_message(
+ chat_id=chat_id,
+ text=f"❌ *Update Failed*\n\n{escape_markdown_v2(str(error_msg)[:200])}",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
+ )
except Exception as e:
logger.error(f"Error updating controller config: {e}", exc_info=True)
- await update.message.reply_text(
- f"❌ *Error*\n\n{escape_markdown_v2(str(e)[:200])}",
- parse_mode="MarkdownV2"
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data=f"bots:ctrl_idx:{controller_idx}")]]
+ await update.get_bot().send_message(
+ chat_id=chat_id,
+ text=f"❌ *Error*\n\n{escape_markdown_v2(str(e)[:200])}",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard)
)
@@ -1013,6 +1369,7 @@ async def handle_stop_bot(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
async def handle_confirm_stop_bot(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Actually stop and archive the bot"""
query = update.callback_query
+ chat_id = update.effective_chat.id
bot_name = context.user_data.get("current_bot_name")
if not bot_name:
@@ -1027,7 +1384,7 @@ async def handle_confirm_stop_bot(update: Update, context: ContextTypes.DEFAULT_
)
try:
- client = await get_bots_client()
+ client = await get_bots_client(chat_id)
result = await client.bot_orchestration.stop_and_archive_bot(
bot_name=bot_name,
@@ -1080,11 +1437,41 @@ async def handle_refresh_bot(update: Update, context: ContextTypes.DEFAULT_TYPE)
if bot_name:
# Clear cache to force refresh
context.user_data.pop("current_bot_info", None)
+ context.user_data.pop("active_bots_data", None)
await show_bot_detail(update, context, bot_name)
else:
await show_bots_menu(update, context)
+async def handle_refresh_controller(update: Update, context: ContextTypes.DEFAULT_TYPE, controller_idx: int) -> None:
+ """Refresh controller detail - clears cache and reloads"""
+ # Clear cache to force fresh data fetch
+ context.user_data.pop("current_bot_info", None)
+ context.user_data.pop("active_bots_data", None)
+ context.user_data.pop("current_controller_config", None)
+
+ # Reload bot info first to get fresh performance data
+ bot_name = context.user_data.get("current_bot_name")
+ chat_id = update.effective_chat.id
+
+ if bot_name:
+ try:
+ client = await get_bots_client(chat_id)
+ fresh_data = await client.bot_orchestration.get_active_bots_status()
+ if isinstance(fresh_data, dict) and "data" in fresh_data:
+ bot_info = fresh_data.get("data", {}).get(bot_name)
+ if bot_info:
+ context.user_data["active_bots_data"] = fresh_data
+ context.user_data["current_bot_info"] = bot_info
+ # Update controllers list
+ performance = bot_info.get("performance", {})
+ context.user_data["current_controllers"] = list(performance.keys())
+ except Exception as e:
+ logger.warning(f"Error refreshing bot data: {e}")
+
+ await show_controller_detail(update, context, controller_idx)
+
+
# ============================================
# VIEW LOGS
# ============================================
diff --git a/handlers/cex/orders.py b/handlers/cex/orders.py
index 5797ad4..7f9e849 100644
--- a/handlers/cex/orders.py
+++ b/handlers/cex/orders.py
@@ -17,7 +17,8 @@ async def handle_search_orders(update: Update, context: ContextTypes.DEFAULT_TYP
try:
from servers import get_client
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
# Search for orders with specified status
if status == "OPEN":
@@ -163,7 +164,8 @@ async def handle_confirm_cancel_order(update: Update, context: ContextTypes.DEFA
from servers import get_client
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
# Cancel the order
result = await client.trading.cancel_order(
diff --git a/handlers/cex/positions.py b/handlers/cex/positions.py
index db67567..4d40d1d 100644
--- a/handlers/cex/positions.py
+++ b/handlers/cex/positions.py
@@ -17,7 +17,8 @@ async def handle_positions(update: Update, context: ContextTypes.DEFAULT_TYPE) -
try:
from servers import get_client
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
# Get all positions
result = await client.trading.get_positions(limit=100)
@@ -237,7 +238,8 @@ async def handle_confirm_close_position(update: Update, context: ContextTypes.DE
from servers import get_client
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
# Place market order to close position
result = await client.trading.place_order(
diff --git a/handlers/cex/trade.py b/handlers/cex/trade.py
index bb87d32..a6a2e6d 100644
--- a/handlers/cex/trade.py
+++ b/handlers/cex/trade.py
@@ -494,6 +494,7 @@ async def show_trade_menu(update: Update, context: ContextTypes.DEFAULT_TYPE,
message = update.callback_query.message
# Store message for later editing
+ chat_id = update.effective_chat.id
if message:
context.user_data["trade_menu_message_id"] = message.message_id
context.user_data["trade_menu_chat_id"] = message.chat_id
@@ -501,7 +502,7 @@ async def show_trade_menu(update: Update, context: ContextTypes.DEFAULT_TYPE,
# Launch background data fetch if needed (when any key data is missing)
needs_fetch = balances is None or quote_data is None or current_price is None
if auto_fetch and message and needs_fetch:
- asyncio.create_task(_fetch_trade_data_background(context, message, params))
+ asyncio.create_task(_fetch_trade_data_background(context, message, params, chat_id))
async def _update_trade_message(context: ContextTypes.DEFAULT_TYPE, message) -> None:
@@ -546,7 +547,8 @@ async def _update_trade_message(context: ContextTypes.DEFAULT_TYPE, message) ->
async def _fetch_trade_data_background(
context: ContextTypes.DEFAULT_TYPE,
message,
- params: dict
+ params: dict,
+ chat_id: int = None
) -> None:
"""Fetch trade data in background and update message when done (like swap.py)"""
logger.info(f"Starting background fetch for trade data...")
@@ -556,7 +558,7 @@ async def _fetch_trade_data_background(
is_perpetual = _is_perpetual_connector(connector)
try:
- client = await get_client()
+ client = await get_client(chat_id)
except Exception as e:
logger.warning(f"Could not get client for trade data: {e}")
return
@@ -750,7 +752,8 @@ async def handle_trade_get_quote(update: Update, context: ContextTypes.DEFAULT_T
# Parse amount (remove $ if present)
volume = float(str(amount).replace("$", ""))
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
# If amount is in USD, we need to convert to base token volume
if "$" in str(amount):
@@ -885,7 +888,8 @@ async def handle_trade_set_connector(update: Update, context: ContextTypes.DEFAU
keyboard = []
try:
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
cex_connectors = await get_available_cex_connectors(context.user_data, client)
# Build buttons (2 per row)
@@ -1039,7 +1043,8 @@ async def handle_trade_toggle_pos_mode(update: Update, context: ContextTypes.DEF
account = get_clob_account(context.user_data)
try:
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
# Get current mode
current_mode = context.user_data.get(_get_position_mode_cache_key(connector), "HEDGE")
@@ -1088,7 +1093,8 @@ async def handle_trade_execute(update: Update, context: ContextTypes.DEFAULT_TYP
if order_type in ["LIMIT", "LIMIT_MAKER"] and (not price or price == "—"):
raise ValueError("Price required for LIMIT orders")
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
# Handle USD amount
is_quote_amount = "$" in str(amount)
@@ -1337,7 +1343,8 @@ async def process_trade_set_leverage(
trading_pair = params.get("trading_pair", "BTC-USDT")
account = get_clob_account(context.user_data)
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
# Set leverage on exchange
await client.trading.set_leverage(
diff --git a/handlers/config/__init__.py b/handlers/config/__init__.py
index dd464bd..671aa35 100644
--- a/handlers/config/__init__.py
+++ b/handlers/config/__init__.py
@@ -31,12 +31,10 @@ def _get_config_menu_markup_and_text():
[
InlineKeyboardButton("🔌 API Servers", callback_data="config_api_servers"),
InlineKeyboardButton("🔑 API Keys", callback_data="config_api_keys"),
- ],
- [
InlineKeyboardButton("🌐 Gateway", callback_data="config_gateway"),
],
[
- InlineKeyboardButton("❌ Close", callback_data="config_close"),
+ InlineKeyboardButton("❌ Cancel", callback_data="config_close"),
],
]
reply_markup = InlineKeyboardMarkup(keyboard)
diff --git a/handlers/config/api_keys.py b/handlers/config/api_keys.py
index 8173625..6fc662e 100644
--- a/handlers/config/api_keys.py
+++ b/handlers/config/api_keys.py
@@ -27,9 +27,11 @@ async def show_api_keys(query, context: ContextTypes.DEFAULT_TYPE) -> None:
keyboard = [[InlineKeyboardButton("« Back", callback_data="config_back")]]
else:
# Build header with server context
+ chat_id = query.message.chat_id
header, server_online, _ = await build_config_message_header(
"🔑 API Keys",
- include_gateway=False
+ include_gateway=False,
+ chat_id=chat_id
)
if not server_online:
@@ -39,8 +41,8 @@ async def show_api_keys(query, context: ContextTypes.DEFAULT_TYPE) -> None:
)
keyboard = [[InlineKeyboardButton("« Back", callback_data="config_back")]]
else:
- # Get client from default server
- client = await server_manager.get_default_client()
+ # Get client from per-chat server
+ client = await server_manager.get_client_for_chat(chat_id)
accounts = await client.accounts.list_accounts()
if not accounts:
@@ -242,13 +244,16 @@ async def show_account_credentials(query, context: ContextTypes.DEFAULT_TYPE, ac
try:
from servers import server_manager
+ chat_id = query.message.chat_id
+
# Build header with server context
header, server_online, _ = await build_config_message_header(
f"🔑 API Keys",
- include_gateway=False
+ include_gateway=False,
+ chat_id=chat_id
)
- client = await server_manager.get_default_client()
+ client = await server_manager.get_client_for_chat(chat_id)
# Get list of connected credentials for this account
credentials = await client.accounts.list_account_credentials(account_name=account_name)
@@ -290,8 +295,8 @@ async def show_account_credentials(query, context: ContextTypes.DEFAULT_TYPE, ac
# Get list of available connectors
all_connectors = await client.connectors.list_connectors()
- # Filter out testnet connectors
- connectors = [c for c in all_connectors if 'testnet' not in c.lower()]
+ # Filter out testnet connectors and gateway connectors (those with '/' like "uniswap/ethereum")
+ connectors = [c for c in all_connectors if 'testnet' not in c.lower() and '/' not in c]
# Create connector buttons in grid of 3 per row (for better readability of long names)
# Store account name and connector list in context to avoid exceeding 64-byte callback_data limit
@@ -337,7 +342,8 @@ async def show_connector_config(query, context: ContextTypes.DEFAULT_TYPE, accou
try:
from servers import server_manager
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
# Get config map for this connector
config_fields = await client.connectors.get_config_map(connector_name)
@@ -475,7 +481,7 @@ async def submit_api_key_config(context: ContextTypes.DEFAULT_TYPE, bot, chat_id
parse_mode="MarkdownV2"
)
- client = await server_manager.get_default_client()
+ client = await server_manager.get_client_for_chat(chat_id)
# Add credentials using the accounts API
await client.accounts.add_credential(
@@ -536,8 +542,67 @@ async def submit_api_key_config(context: ContextTypes.DEFAULT_TYPE, bot, chat_id
except Exception as e:
logger.error(f"Error submitting API key config: {e}", exc_info=True)
- error_text = f"❌ Error saving configuration: {escape_markdown_v2(str(e))}"
- await bot.send_message(chat_id=chat_id, text=error_text, parse_mode="MarkdownV2")
+
+ # Get account name for back button before clearing state
+ config_data = context.user_data.get('api_key_config_data', {})
+ account_name = config_data.get('account_name', '')
+ connector_name = config_data.get('connector_name', '')
+ message_id = context.user_data.get('api_key_message_id')
+
+ # Clear context data so user can retry
+ context.user_data.pop('configuring_api_key', None)
+ context.user_data.pop('awaiting_api_key_input', None)
+ context.user_data.pop('api_key_config_data', None)
+ context.user_data.pop('api_key_message_id', None)
+ context.user_data.pop('api_key_chat_id', None)
+
+ # Build error message with more helpful text for timeout
+ error_str = str(e)
+ if "TimeoutError" in error_str or "timeout" in error_str.lower():
+ connector_escaped = escape_markdown_v2(connector_name)
+ error_text = (
+ f"❌ *Connection Timeout*\n\n"
+ f"Failed to verify credentials for *{connector_escaped}*\\.\n\n"
+ "The exchange took too long to respond\\. "
+ "Please check your API keys and try again\\."
+ )
+ else:
+ error_text = f"❌ Error saving configuration: {escape_markdown_v2(error_str)}"
+
+ # Add back button to navigate back to account
+ if account_name:
+ encoded_account = base64.b64encode(account_name.encode()).decode()
+ keyboard = [[InlineKeyboardButton("« Back to Account", callback_data=f"api_key_back_account:{encoded_account}")]]
+ else:
+ keyboard = [[InlineKeyboardButton("« Back", callback_data="api_key_back_to_accounts")]]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ # Try to edit existing message, fall back to sending new message
+ try:
+ if message_id and chat_id:
+ await bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ else:
+ await bot.send_message(
+ chat_id=chat_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ except Exception as msg_error:
+ logger.error(f"Failed to send error message: {msg_error}")
+ # Last resort: send simple message
+ await bot.send_message(
+ chat_id=chat_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
async def delete_credential(query, context: ContextTypes.DEFAULT_TYPE, account_name: str, connector_name: str) -> None:
@@ -547,7 +612,8 @@ async def delete_credential(query, context: ContextTypes.DEFAULT_TYPE, account_n
try:
from servers import server_manager
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
# Delete the credential
await client.accounts.delete_credential(
diff --git a/handlers/config/gateway/connectors.py b/handlers/config/gateway/connectors.py
index 59ca194..6977753 100644
--- a/handlers/config/gateway/connectors.py
+++ b/handlers/config/gateway/connectors.py
@@ -15,7 +15,8 @@ async def show_connectors_menu(query, context: ContextTypes.DEFAULT_TYPE) -> Non
await query.answer("Loading connectors...")
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
response = await client.gateway.list_connectors()
connectors = response.get('connectors', [])
@@ -114,7 +115,8 @@ async def show_connector_details(query, context: ContextTypes.DEFAULT_TYPE, conn
try:
from servers import server_manager
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
response = await client.gateway.get_connector_config(connector_name)
# Try to extract config - it might be directly in response or nested under 'config'
@@ -179,7 +181,8 @@ async def start_connector_config_edit(query, context: ContextTypes.DEFAULT_TYPE,
try:
from servers import server_manager
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
response = await client.gateway.get_connector_config(connector_name)
# Extract config
@@ -393,7 +396,7 @@ async def submit_connector_config(context: ContextTypes.DEFAULT_TYPE, bot, chat_
parse_mode="MarkdownV2"
)
- client = await server_manager.get_default_client()
+ client = await server_manager.get_client_for_chat(chat_id)
# Update configuration using the gateway API
await client.gateway.update_connector_config(connector_name, final_config)
diff --git a/handlers/config/gateway/deployment.py b/handlers/config/gateway/deployment.py
index c81a95c..2b70f2f 100644
--- a/handlers/config/gateway/deployment.py
+++ b/handlers/config/gateway/deployment.py
@@ -12,9 +12,11 @@
async def start_deploy_gateway(query, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Show Docker image selection for Gateway deployment"""
try:
+ chat_id = query.message.chat_id
header, server_online, _ = await build_config_message_header(
"🚀 Deploy Gateway",
- include_gateway=False
+ include_gateway=False,
+ chat_id=chat_id
)
if not server_online:
@@ -64,7 +66,8 @@ async def deploy_gateway_with_image(query, context: ContextTypes.DEFAULT_TYPE) -
await query.answer("🚀 Deploying Gateway...")
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
# Gateway configuration
config = {
@@ -95,9 +98,11 @@ async def deploy_gateway_with_image(query, context: ContextTypes.DEFAULT_TYPE) -
async def prompt_custom_image(query, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Prompt user to enter custom Docker image"""
try:
+ chat_id = query.message.chat_id
header, server_online, _ = await build_config_message_header(
"✏️ Custom Gateway Image",
- include_gateway=False
+ include_gateway=False,
+ chat_id=chat_id
)
context.user_data['awaiting_gateway_input'] = 'custom_image'
@@ -129,14 +134,15 @@ async def prompt_custom_image(query, context: ContextTypes.DEFAULT_TYPE) -> None
async def stop_gateway(query, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Stop Gateway container on the default server"""
+ """Stop Gateway container on the current server"""
try:
from servers import server_manager
from .menu import show_gateway_menu
await query.answer("⏹ Stopping Gateway...")
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
response = await client.gateway.stop()
if response.get('status') == 'success' or response.get('status') == 'stopped':
@@ -155,19 +161,22 @@ async def stop_gateway(query, context: ContextTypes.DEFAULT_TYPE) -> None:
async def restart_gateway(query, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Restart Gateway container on the default server"""
+ """Restart Gateway container on the current server"""
try:
from servers import server_manager
from .menu import show_gateway_menu
import asyncio
+ chat_id = query.message.chat_id
+
# Answer the callback query first
await query.answer("🔄 Restarting Gateway...")
# Update message to show restarting status
header, _, _ = await build_config_message_header(
"🌐 Gateway Configuration",
- include_gateway=False # Don't check status during restart
+ include_gateway=False, # Don't check status during restart
+ chat_id=chat_id
)
restarting_text = (
@@ -185,7 +194,7 @@ async def restart_gateway(query, context: ContextTypes.DEFAULT_TYPE) -> None:
pass # Ignore if message can't be edited
# Perform the restart
- client = await server_manager.get_default_client()
+ client = await server_manager.get_client_for_chat(chat_id)
response = await client.gateway.restart()
# Wait a moment for the restart to take effect
@@ -235,7 +244,8 @@ async def show_gateway_logs(query, context: ContextTypes.DEFAULT_TYPE) -> None:
await query.answer("📋 Loading logs...")
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
response = await client.gateway.get_logs(tail=50)
logs = response.get('logs', 'No logs available')
diff --git a/handlers/config/gateway/menu.py b/handlers/config/gateway/menu.py
index d41492c..25330a9 100644
--- a/handlers/config/gateway/menu.py
+++ b/handlers/config/gateway/menu.py
@@ -23,9 +23,11 @@ async def show_gateway_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None:
keyboard = [[InlineKeyboardButton("« Back", callback_data="config_back")]]
else:
# Build unified header with server and gateway info
+ chat_id = query.message.chat_id
header, server_online, gateway_running = await build_config_message_header(
"🌐 Gateway Configuration",
- include_gateway=True
+ include_gateway=True,
+ chat_id=chat_id
)
message_text = header
diff --git a/handlers/config/gateway/networks.py b/handlers/config/gateway/networks.py
index da3c024..d277e06 100644
--- a/handlers/config/gateway/networks.py
+++ b/handlers/config/gateway/networks.py
@@ -15,7 +15,8 @@ async def show_networks_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None:
await query.answer("Loading networks...")
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
response = await client.gateway.list_networks()
networks = response.get('networks', [])
@@ -102,17 +103,6 @@ async def handle_network_action(query, context: ContextTypes.DEFAULT_TYPE) -> No
# Fallback for old-style callback data
network_id = network_idx_str
await show_network_details(query, context, network_id)
- elif action_data == "edit_config":
- # Start network configuration editing
- network_id = context.user_data.get('current_network_id')
- if network_id:
- await start_network_config_edit(query, context, network_id)
- else:
- await query.answer("❌ Network not found")
- elif action_data == "config_keep":
- await handle_network_config_keep(query, context)
- elif action_data == "config_back":
- await handle_network_config_back(query, context)
elif action_data == "config_cancel":
await handle_network_config_cancel(query, context)
else:
@@ -120,51 +110,56 @@ async def handle_network_action(query, context: ContextTypes.DEFAULT_TYPE) -> No
async def show_network_details(query, context: ContextTypes.DEFAULT_TYPE, network_id: str) -> None:
- """Show details and configuration for a specific network"""
+ """Show network config in edit mode - user can copy/paste to change values"""
try:
from servers import server_manager
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
response = await client.gateway.get_network_config(network_id)
# Try to extract config - it might be directly in response or nested under 'config'
if isinstance(response, dict):
- # If response has a 'config' key, use that; otherwise use the whole response
config = response.get('config', response) if 'config' in response else response
else:
config = {}
+ # Filter out metadata fields
+ config_fields = {k: v for k, v in config.items() if k not in ['status', 'message', 'error']}
+
network_escaped = escape_markdown_v2(network_id)
- # Build configuration display
- config_lines = []
- for key, value in config.items():
- key_escaped = escape_markdown_v2(str(key))
- # Truncate long values like URLs
- value_str = str(value)
- if len(value_str) > 50:
- value_str = value_str[:47] + "..."
- value_escaped = escape_markdown_v2(value_str)
- config_lines.append(f"• *{key_escaped}:* `{value_escaped}`")
-
- if config_lines:
- config_text = "\n".join(config_lines)
+ if not config_fields:
+ message_text = (
+ f"🌍 *Network: {network_escaped}*\n\n"
+ "_No configuration available_"
+ )
+ keyboard = [[InlineKeyboardButton("« Back", callback_data="gateway_networks")]]
else:
- config_text = "_No configuration available_"
+ # Build copyable config for editing
+ config_lines = []
+ for key, value in config_fields.items():
+ config_lines.append(f"{key}={value}")
- message_text = (
- f"🌍 *Network: {network_escaped}*\n\n"
- "*Configuration:*\n"
- f"{config_text}"
- )
+ config_text = "\n".join(config_lines)
+
+ message_text = (
+ f"🌍 *{network_escaped}*\n\n"
+ f"```\n{config_text}\n```\n\n"
+ f"✏️ _Send `key=value` to update_"
+ )
- # Store network_id in context for edit action
- context.user_data['current_network_id'] = network_id
+ # Set up editing state
+ context.user_data['configuring_network'] = True
+ context.user_data['network_config_data'] = {
+ 'network_id': network_id,
+ 'current_values': config_fields.copy(),
+ }
+ context.user_data['awaiting_network_input'] = 'bulk_edit'
+ context.user_data['network_message_id'] = query.message.message_id
+ context.user_data['network_chat_id'] = query.message.chat_id
- keyboard = [
- [InlineKeyboardButton("✏️ Edit Configuration", callback_data=f"gateway_network_edit_config")],
- [InlineKeyboardButton("« Back to Networks", callback_data="gateway_networks")]
- ]
+ keyboard = [[InlineKeyboardButton("« Back", callback_data="gateway_networks")]]
reply_markup = InlineKeyboardMarkup(keyboard)
@@ -182,133 +177,10 @@ async def show_network_details(query, context: ContextTypes.DEFAULT_TYPE, networ
await query.message.edit_text(error_text, parse_mode="MarkdownV2", reply_markup=reply_markup)
-async def start_network_config_edit(query, context: ContextTypes.DEFAULT_TYPE, network_id: str) -> None:
- """Start progressive configuration editing flow for a network"""
- try:
- from servers import server_manager
-
- client = await server_manager.get_default_client()
- response = await client.gateway.get_network_config(network_id)
-
- # Try to extract config - it might be directly in response or nested under 'config'
- if isinstance(response, dict):
- # If response has a 'config' key, use that; otherwise use the whole response
- config = response.get('config', response) if 'config' in response else response
- else:
- config = {}
-
- # Filter out metadata fields
- config_fields = {k: v for k, v in config.items() if k not in ['status', 'message', 'error']}
- field_names = list(config_fields.keys())
-
- if not field_names:
- await query.answer("❌ No configurable fields found")
- return
-
- # Initialize context storage for network configuration
- context.user_data['configuring_network'] = True
- context.user_data['network_config_data'] = {
- 'network_id': network_id,
- 'fields': field_names,
- 'current_values': config_fields.copy(),
- 'new_values': {}
- }
- context.user_data['awaiting_network_input'] = field_names[0]
- context.user_data['network_message_id'] = query.message.message_id
- context.user_data['network_chat_id'] = query.message.chat_id
-
- # Show first field
- message_text, reply_markup = _build_network_config_message(
- context.user_data['network_config_data'],
- field_names[0],
- field_names
- )
-
- await query.message.edit_text(
- message_text,
- parse_mode="MarkdownV2",
- reply_markup=reply_markup
- )
- await query.answer()
-
- except Exception as e:
- logger.error(f"Error starting network config edit: {e}", exc_info=True)
- error_text = f"❌ Error loading configuration: {escape_markdown_v2(str(e))}"
- keyboard = [[InlineKeyboardButton("« Back", callback_data="gateway_networks")]]
- reply_markup = InlineKeyboardMarkup(keyboard)
- await query.message.edit_text(error_text, parse_mode="MarkdownV2", reply_markup=reply_markup)
-
-
-async def handle_network_config_back(query, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle back button during network configuration"""
- config_data = context.user_data.get('network_config_data', {})
- all_fields = config_data.get('fields', [])
- current_field = context.user_data.get('awaiting_network_input')
-
- if current_field and current_field in all_fields:
- current_index = all_fields.index(current_field)
- if current_index > 0:
- # Go to previous field
- previous_field = all_fields[current_index - 1]
-
- # Remove the previous field's new value to re-enter it
- new_values = config_data.get('new_values', {})
- new_values.pop(previous_field, None)
- config_data['new_values'] = new_values
- context.user_data['network_config_data'] = config_data
-
- # Update awaiting field
- context.user_data['awaiting_network_input'] = previous_field
- await query.answer("« Going back")
- await _update_network_config_message(context, query.message.get_bot())
- else:
- await query.answer("Cannot go back")
- else:
- await query.answer("Cannot go back")
-
-
-async def handle_network_config_keep(query, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle keep current value button during network configuration"""
- try:
- awaiting_field = context.user_data.get('awaiting_network_input')
- if not awaiting_field:
- await query.answer("No field to keep")
- return
-
- config_data = context.user_data.get('network_config_data', {})
- new_values = config_data.get('new_values', {})
- all_fields = config_data.get('fields', [])
- current_values = config_data.get('current_values', {})
-
- # Use the current value
- current_val = current_values.get(awaiting_field)
- new_values[awaiting_field] = current_val
- config_data['new_values'] = new_values
- context.user_data['network_config_data'] = config_data
-
- await query.answer("✓ Keeping current value")
-
- # Move to next field or show confirmation
- current_index = all_fields.index(awaiting_field)
-
- if current_index < len(all_fields) - 1:
- # Move to next field
- context.user_data['awaiting_network_input'] = all_fields[current_index + 1]
- await _update_network_config_message(context, query.message.get_bot())
- else:
- # All fields filled - submit configuration
- context.user_data['awaiting_network_input'] = None
- await submit_network_config(context, query.message.get_bot(), query.message.chat_id)
-
- except Exception as e:
- logger.error(f"Error handling keep current value: {e}", exc_info=True)
- await query.answer(f"❌ Error: {str(e)[:100]}")
-
-
async def handle_network_config_input(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle text input during network configuration flow"""
+ """Handle text input during network configuration - parses key=value lines"""
awaiting_field = context.user_data.get('awaiting_network_input')
- if not awaiting_field:
+ if awaiting_field != 'bulk_edit':
return
# Delete user's input message for clean chat
@@ -318,43 +190,63 @@ async def handle_network_config_input(update: Update, context: ContextTypes.DEFA
pass
try:
- new_value = update.message.text.strip()
+ input_text = update.message.text.strip()
config_data = context.user_data.get('network_config_data', {})
- new_values = config_data.get('new_values', {})
- all_fields = config_data.get('fields', [])
current_values = config_data.get('current_values', {})
- # Convert value to appropriate type based on current value
- current_val = current_values.get(awaiting_field)
- try:
- if isinstance(current_val, bool):
- # Handle boolean conversion
- new_value = new_value.lower() in ['true', '1', 'yes', 'y', 'on']
- elif isinstance(current_val, int):
- new_value = int(new_value)
- elif isinstance(current_val, float):
- new_value = float(new_value)
- # else keep as string
- except ValueError:
- # If conversion fails, keep as string
- pass
+ # Parse key=value lines
+ updates = {}
+ errors = []
+
+ for line in input_text.split('\n'):
+ line = line.strip()
+ if not line or '=' not in line:
+ continue
+
+ key, _, value = line.partition('=')
+ key = key.strip()
+ value = value.strip()
+
+ # Validate key exists in config
+ if key not in current_values:
+ errors.append(f"Unknown key: {key}")
+ continue
+
+ # Convert value to appropriate type based on current value
+ current_val = current_values.get(key)
+ try:
+ if isinstance(current_val, bool):
+ value = value.lower() in ['true', '1', 'yes', 'y', 'on']
+ elif isinstance(current_val, int):
+ value = int(value)
+ elif isinstance(current_val, float):
+ value = float(value)
+ except ValueError:
+ pass # Keep as string
+
+ updates[key] = value
+
+ if errors:
+ # Show errors but don't cancel
+ error_msg = "⚠️ " + ", ".join(errors)
+ await update.get_bot().send_message(
+ chat_id=update.effective_chat.id,
+ text=error_msg
+ )
- # Store the new value
- new_values[awaiting_field] = new_value
- config_data['new_values'] = new_values
- context.user_data['network_config_data'] = config_data
+ if not updates:
+ await update.get_bot().send_message(
+ chat_id=update.effective_chat.id,
+ text="❌ No valid updates found. Use format: key=value"
+ )
+ return
- # Move to next field or show confirmation
- current_index = all_fields.index(awaiting_field)
+ # Store updates and submit
+ config_data['new_values'] = updates
+ context.user_data['network_config_data'] = config_data
+ context.user_data['awaiting_network_input'] = None
- if current_index < len(all_fields) - 1:
- # Move to next field
- context.user_data['awaiting_network_input'] = all_fields[current_index + 1]
- await _update_network_config_message(context, update.get_bot())
- else:
- # All fields filled - submit configuration
- context.user_data['awaiting_network_input'] = None
- await submit_network_config(context, update.get_bot(), update.effective_chat.id)
+ await submit_network_config(context, update.get_bot(), update.effective_chat.id)
except Exception as e:
logger.error(f"Error handling network config input: {e}", exc_info=True)
@@ -400,7 +292,7 @@ async def submit_network_config(context: ContextTypes.DEFAULT_TYPE, bot, chat_id
context.user_data.pop('awaiting_network_input', None)
# Submit configuration to Gateway
- client = await server_manager.get_default_client()
+ client = await server_manager.get_client_for_chat(chat_id)
await client.gateway.update_network_config(network_id, final_config)
success_text = f"✅ Configuration saved for {escape_markdown_v2(network_id)}\\!"
@@ -435,122 +327,6 @@ async def submit_network_config(context: ContextTypes.DEFAULT_TYPE, bot, chat_id
await bot.send_message(chat_id=chat_id, text=error_text, parse_mode="MarkdownV2")
-def _build_network_config_message(config_data: dict, current_field: str, all_fields: list) -> tuple:
- """
- Build the progressive network configuration message
- Returns (message_text, reply_markup)
- """
- network_id = config_data.get('network_id', '')
- current_values = config_data.get('current_values', {})
- new_values = config_data.get('new_values', {})
-
- network_escaped = escape_markdown_v2(network_id)
-
- # Build the message showing progress
- lines = [f"✏️ *Edit {network_escaped}*\n"]
-
- for field in all_fields:
- if field in new_values:
- # Field already filled with new value - show it
- value = new_values[field]
- # Mask sensitive values
- if 'key' in field.lower() or 'secret' in field.lower() or 'password' in field.lower():
- value = '***' if value else ''
- field_escaped = escape_markdown_v2(field)
- value_escaped = escape_markdown_v2(str(value))
- lines.append(f"*{field_escaped}:* `{value_escaped}` ✅")
- elif field == current_field:
- # Current field being filled - show current value as default
- current_val = current_values.get(field, '')
- # Mask sensitive values
- if 'key' in field.lower() or 'secret' in field.lower() or 'password' in field.lower():
- current_val = '***' if current_val else ''
- field_escaped = escape_markdown_v2(field)
- current_escaped = escape_markdown_v2(str(current_val))
- lines.append(f"*{field_escaped}:* _\\(current: `{current_escaped}`\\)_")
- lines.append("_Enter new value or keep current:_")
- break
- else:
- # Future field - show current value
- current_val = current_values.get(field, '')
- if 'key' in field.lower() or 'secret' in field.lower() or 'password' in field.lower():
- current_val = '***' if current_val else ''
- field_escaped = escape_markdown_v2(field)
- current_escaped = escape_markdown_v2(str(current_val))
- lines.append(f"*{field_escaped}:* `{current_escaped}`")
-
- message_text = "\n".join(lines)
-
- # Build keyboard with back and cancel buttons
- buttons = []
-
- # Get current value for "Keep current" button
- current_val = current_values.get(current_field, '')
-
- # Always add "Keep current" button (even for empty/None values)
- keep_buttons = []
-
- # Check if value is empty, None, or null-like
- is_empty = current_val is None or current_val == '' or str(current_val).lower() in ['none', 'null']
-
- if is_empty:
- # Show "Keep empty" for empty values
- button_text = "Keep empty"
- elif 'key' in current_field.lower() or 'secret' in current_field.lower() or 'password' in current_field.lower():
- # Don't show the actual value if it's sensitive
- button_text = "Keep current: ***"
- else:
- # Truncate long values
- display_val = str(current_val)
- if len(display_val) > 20:
- display_val = display_val[:17] + "..."
- button_text = f"Keep: {display_val}"
-
- keep_buttons.append(InlineKeyboardButton(button_text, callback_data="gateway_network_config_keep"))
- buttons.append(keep_buttons)
-
- # Back button (only if not on first field)
- current_index = all_fields.index(current_field)
- if current_index > 0:
- buttons.append([InlineKeyboardButton("« Back", callback_data="gateway_network_config_back")])
-
- # Cancel button
- buttons.append([InlineKeyboardButton("✖️ Cancel", callback_data="gateway_network_config_cancel")])
-
- reply_markup = InlineKeyboardMarkup(buttons)
- return (message_text, reply_markup)
-
-
-async def _update_network_config_message(context: ContextTypes.DEFAULT_TYPE, bot) -> None:
- """Update the network config message with the current field"""
- try:
- config_data = context.user_data.get('network_config_data', {})
- all_fields = config_data.get('fields', [])
- current_field = context.user_data.get('awaiting_network_input')
-
- if not current_field or not all_fields:
- return
-
- message_text, reply_markup = _build_network_config_message(
- config_data,
- current_field,
- all_fields
- )
-
- message_id = context.user_data.get('network_message_id')
- chat_id = context.user_data.get('network_chat_id')
-
- await bot.edit_message_text(
- chat_id=chat_id,
- message_id=message_id,
- text=message_text,
- parse_mode="MarkdownV2",
- reply_markup=reply_markup
- )
- except Exception as e:
- logger.error(f"Error updating network config message: {e}", exc_info=True)
-
-
async def handle_network_config_cancel(query, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle cancel button during network configuration"""
try:
diff --git a/handlers/config/gateway/pools.py b/handlers/config/gateway/pools.py
index 0ad50d6..842881f 100644
--- a/handlers/config/gateway/pools.py
+++ b/handlers/config/gateway/pools.py
@@ -17,7 +17,8 @@ async def show_pools_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None:
await query.answer("Loading connectors...")
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
response = await client.gateway.list_connectors()
connectors = response.get('connectors', [])
@@ -176,7 +177,8 @@ async def show_pool_networks(query, context: ContextTypes.DEFAULT_TYPE, connecto
if not connector_info:
# Fallback: fetch connector info again if not in context
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
response = await client.gateway.list_connectors()
connectors = response.get('connectors', [])
connector_info = next((c for c in connectors if c.get('name') == connector_name), None)
@@ -250,7 +252,8 @@ async def show_connector_pools(query, context: ContextTypes.DEFAULT_TYPE, connec
await query.answer("Loading pools...")
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
pools = await client.gateway.list_pools(connector_name=connector_name, network=network)
connector_escaped = escape_markdown_v2(connector_name)
@@ -369,8 +372,10 @@ async def prompt_remove_pool(query, context: ContextTypes.DEFAULT_TYPE, connecto
connector_escaped = escape_markdown_v2(connector_name)
network_escaped = escape_markdown_v2(network)
+ chat_id = query.message.chat_id
+
# Fetch pools to display as options
- client = await server_manager.get_default_client()
+ client = await server_manager.get_client_for_chat(chat_id)
pools = await client.gateway.list_pools(connector_name=connector_name, network=network)
if not pools:
@@ -478,7 +483,8 @@ async def remove_pool(query, context: ContextTypes.DEFAULT_TYPE, connector_name:
except TypeError:
pass # Mock query doesn't support answer
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
await client.gateway.delete_pool(connector=connector_name, network=network, pool_type=pool_type, address=pool_address)
connector_escaped = escape_markdown_v2(connector_name)
@@ -577,7 +583,7 @@ async def handle_pool_input(update: Update, context: ContextTypes.DEFAULT_TYPE)
)
try:
- client = await server_manager.get_default_client()
+ client = await server_manager.get_client_for_chat(chat_id)
logger.info(f"Adding pool: connector={connector_name}, network={network}, "
f"pool_type={pool_type}, base={base}, quote={quote}, address={address}, "
diff --git a/handlers/config/gateway/tokens.py b/handlers/config/gateway/tokens.py
index bade93b..0bba80e 100644
--- a/handlers/config/gateway/tokens.py
+++ b/handlers/config/gateway/tokens.py
@@ -35,7 +35,8 @@ async def show_tokens_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None:
await query.answer("Loading networks...")
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
response = await client.gateway.list_networks()
networks = response.get('networks', [])
@@ -181,7 +182,8 @@ async def show_network_tokens(query, context: ContextTypes.DEFAULT_TYPE, network
await query.answer("Loading tokens...")
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
# Try to get tokens - the method might not exist in older versions
try:
@@ -336,7 +338,8 @@ async def prompt_remove_token(query, context: ContextTypes.DEFAULT_TYPE, network
await query.answer("Loading tokens...")
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
# Get tokens for the network
try:
@@ -513,8 +516,10 @@ async def show_delete_token_confirmation(query, context: ContextTypes.DEFAULT_TY
try:
from servers import server_manager
+ chat_id = query.message.chat_id
+
# Get token details to show in confirmation
- client = await server_manager.get_default_client()
+ client = await server_manager.get_client_for_chat(chat_id)
# Try to get tokens - the method might not exist in older versions
try:
@@ -584,7 +589,8 @@ async def remove_token(query, context: ContextTypes.DEFAULT_TYPE, network_id: st
await query.answer("Removing token...")
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
await client.gateway.delete_token(network_id=network_id, token_address=token_address)
network_escaped = escape_markdown_v2(network_id)
@@ -718,7 +724,7 @@ async def handle_token_input(update: Update, context: ContextTypes.DEFAULT_TYPE)
)
try:
- client = await server_manager.get_default_client()
+ client = await server_manager.get_client_for_chat(chat_id)
await client.gateway.add_token(
network_id=network_id,
address=address,
@@ -851,7 +857,7 @@ async def mock_answer(text=""):
)
try:
- client = await server_manager.get_default_client()
+ client = await server_manager.get_client_for_chat(chat_id)
# Delete old token first, then add with new values
await client.gateway.delete_token(network_id=network_id, token_address=token_address)
diff --git a/handlers/config/gateway/wallets.py b/handlers/config/gateway/wallets.py
index 0c95d68..ad5459f 100644
--- a/handlers/config/gateway/wallets.py
+++ b/handlers/config/gateway/wallets.py
@@ -6,17 +6,25 @@
from telegram.ext import ContextTypes
from ..server_context import build_config_message_header
+from ..user_preferences import (
+ get_wallet_networks,
+ set_wallet_networks,
+ remove_wallet_networks,
+ get_default_networks_for_chain,
+ get_all_networks_for_chain,
+)
from ._shared import logger, escape_markdown_v2
async def show_wallets_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Show wallets management menu with list of connected wallets"""
+ """Show wallets management menu with list of connected wallets as clickable buttons"""
try:
from servers import server_manager
await query.answer("Loading wallets...")
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
# Get list of gateway wallets
try:
@@ -28,7 +36,8 @@ async def show_wallets_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None:
header, server_online, gateway_running = await build_config_message_header(
"🔑 Wallet Management",
- include_gateway=True
+ include_gateway=True,
+ chat_id=chat_id
)
if not server_online:
@@ -54,37 +63,40 @@ async def show_wallets_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None:
[InlineKeyboardButton("« Back to Gateway", callback_data="config_gateway")]
]
else:
- # Display wallets grouped by chain
- # API returns: [{"chain": "solana", "walletAddresses": ["addr1", "addr2"]}]
- wallet_lines = []
- total_wallets = 0
-
+ # Build a flat list of wallets with chain info for indexing
+ # Store in context for retrieval by index
+ wallet_list = []
for wallet_group in wallets_data:
chain = wallet_group.get('chain', 'unknown')
addresses = wallet_group.get('walletAddresses', [])
- total_wallets += len(addresses)
-
- chain_escaped = escape_markdown_v2(chain.upper())
- wallet_lines.append(f"\n*{chain_escaped}*")
for address in addresses:
- # Truncate address for display
- display_addr = address[:8] + "..." + address[-6:] if len(address) > 20 else address
- addr_escaped = escape_markdown_v2(display_addr)
- wallet_lines.append(f" • `{addr_escaped}`")
+ wallet_list.append({'chain': chain, 'address': address})
+
+ context.user_data['wallet_list'] = wallet_list
+ total_wallets = len(wallet_list)
wallet_count = escape_markdown_v2(str(total_wallets))
message_text = (
header +
- f"*Connected Wallets:* {wallet_count}\n" +
- "\n".join(wallet_lines) + "\n\n"
- "_Select an action:_"
+ f"*Connected Wallets:* {wallet_count}\n\n"
+ "_Click a wallet to view details and configure networks\\._"
)
- keyboard = [
- [
- InlineKeyboardButton("➕ Add Wallet", callback_data="gateway_wallet_add"),
- InlineKeyboardButton("➖ Remove Wallet", callback_data="gateway_wallet_remove")
- ],
+ # Create wallet buttons - one per row with chain prefix
+ wallet_buttons = []
+ for idx, wallet in enumerate(wallet_list):
+ chain = wallet['chain']
+ address = wallet['address']
+ # Truncate address for display
+ display_addr = address[:6] + "..." + address[-4:] if len(address) > 14 else address
+ chain_icon = "🟣" if chain == "solana" else "🔵" # Solana purple, Ethereum blue
+ button_text = f"{chain_icon} {chain.title()}: {display_addr}"
+ wallet_buttons.append([
+ InlineKeyboardButton(button_text, callback_data=f"gateway_wallet_view_{idx}")
+ ])
+
+ keyboard = wallet_buttons + [
+ [InlineKeyboardButton("➕ Add Wallet", callback_data="gateway_wallet_add")],
[
InlineKeyboardButton("🔄 Refresh", callback_data="gateway_wallets"),
InlineKeyboardButton("« Back to Gateway", callback_data="config_gateway")
@@ -121,6 +133,19 @@ async def handle_wallet_action(query, context: ContextTypes.DEFAULT_TYPE) -> Non
await prompt_add_wallet_chain(query, context)
elif action_data == "remove":
await prompt_remove_wallet_chain(query, context)
+ elif action_data.startswith("view_"):
+ # View wallet details by index
+ idx_str = action_data.replace("view_", "")
+ try:
+ idx = int(idx_str)
+ wallet_list = context.user_data.get('wallet_list', [])
+ if 0 <= idx < len(wallet_list):
+ wallet = wallet_list[idx]
+ await show_wallet_details(query, context, wallet['chain'], wallet['address'])
+ else:
+ await query.answer("❌ Wallet not found")
+ except ValueError:
+ await query.answer("❌ Invalid wallet index")
elif action_data.startswith("add_chain_"):
chain = action_data.replace("add_chain_", "")
await prompt_add_wallet_private_key(query, context, chain)
@@ -143,8 +168,68 @@ async def handle_wallet_action(query, context: ContextTypes.DEFAULT_TYPE) -> Non
await query.answer("❌ Invalid wallet selection")
except ValueError:
await query.answer("❌ Invalid wallet index")
+ elif action_data.startswith("delete_"):
+ # Direct delete from wallet detail view: delete_{idx}
+ idx_str = action_data.replace("delete_", "")
+ try:
+ idx = int(idx_str)
+ wallet_list = context.user_data.get('wallet_list', [])
+ if 0 <= idx < len(wallet_list):
+ wallet = wallet_list[idx]
+ await remove_wallet(query, context, wallet['chain'], wallet['address'])
+ else:
+ await query.answer("❌ Wallet not found")
+ except ValueError:
+ await query.answer("❌ Invalid wallet index")
+ elif action_data.startswith("networks_"):
+ # Edit networks for wallet: networks_{idx}
+ idx_str = action_data.replace("networks_", "")
+ try:
+ idx = int(idx_str)
+ wallet_list = context.user_data.get('wallet_list', [])
+ if 0 <= idx < len(wallet_list):
+ wallet = wallet_list[idx]
+ await show_wallet_network_edit(query, context, wallet['chain'], wallet['address'], idx)
+ else:
+ await query.answer("❌ Wallet not found")
+ except ValueError:
+ await query.answer("❌ Invalid wallet index")
+ elif action_data.startswith("toggle_net_"):
+ # Toggle network: toggle_net_{wallet_idx}_{network_id}
+ parts = action_data.replace("toggle_net_", "").split("_", 1)
+ if len(parts) == 2:
+ wallet_idx_str, network_id = parts
+ try:
+ wallet_idx = int(wallet_idx_str)
+ await toggle_wallet_network(query, context, wallet_idx, network_id)
+ except ValueError:
+ await query.answer("❌ Invalid index")
+ elif action_data.startswith("net_done_"):
+ # Done editing networks: net_done_{wallet_idx}
+ idx_str = action_data.replace("net_done_", "")
+ try:
+ idx = int(idx_str)
+ wallet_list = context.user_data.get('wallet_list', [])
+ if 0 <= idx < len(wallet_list):
+ wallet = wallet_list[idx]
+ await show_wallet_details(query, context, wallet['chain'], wallet['address'])
+ else:
+ await show_wallets_menu(query, context)
+ except ValueError:
+ await show_wallets_menu(query, context)
elif action_data == "cancel_add" or action_data == "cancel_remove":
await show_wallets_menu(query, context)
+ elif action_data.startswith("select_networks_"):
+ # After adding wallet, select networks: select_networks_{chain}_{address_truncated}
+ # We use the full address stored in context
+ await show_new_wallet_network_selection(query, context)
+ elif action_data.startswith("new_toggle_"):
+ # Toggle network for newly added wallet: new_toggle_{network_id}
+ network_id = action_data.replace("new_toggle_", "")
+ await toggle_new_wallet_network(query, context, network_id)
+ elif action_data == "new_net_done":
+ # Finish network selection for new wallet
+ await finish_new_wallet_network_selection(query, context)
else:
await query.answer("Unknown action")
@@ -152,13 +237,15 @@ async def handle_wallet_action(query, context: ContextTypes.DEFAULT_TYPE) -> Non
async def prompt_add_wallet_chain(query, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Prompt user to select chain for adding wallet"""
try:
+ chat_id = query.message.chat_id
header, server_online, gateway_running = await build_config_message_header(
"➕ Add Wallet",
- include_gateway=True
+ include_gateway=True,
+ chat_id=chat_id
)
- # Common chains
- supported_chains = ["ethereum", "polygon", "solana", "avalanche", "binance-smart-chain"]
+ # Base blockchain chains (wallets are at blockchain level, not network level)
+ supported_chains = ["ethereum", "solana"]
message_text = (
header +
@@ -192,12 +279,190 @@ async def prompt_add_wallet_chain(query, context: ContextTypes.DEFAULT_TYPE) ->
await query.answer(f"❌ Error: {str(e)[:100]}")
+async def show_wallet_details(query, context: ContextTypes.DEFAULT_TYPE, chain: str, address: str) -> None:
+ """Show details for a specific wallet with edit options"""
+ try:
+ chat_id = query.message.chat_id
+ header, server_online, gateway_running = await build_config_message_header(
+ "🔑 Wallet Details",
+ include_gateway=True,
+ chat_id=chat_id
+ )
+
+ chain_escaped = escape_markdown_v2(chain.title())
+ chain_icon = "🟣" if chain == "solana" else "🔵"
+
+ # Get configured networks for this wallet
+ enabled_networks = get_wallet_networks(context.user_data, address)
+ if enabled_networks is None:
+ # Not configured yet - use defaults
+ enabled_networks = get_default_networks_for_chain(chain)
+
+ # Format address display
+ addr_escaped = escape_markdown_v2(address)
+
+ # Build networks list
+ all_networks = get_all_networks_for_chain(chain)
+ networks_display = []
+ for net in all_networks:
+ is_enabled = net in enabled_networks
+ status = "✅" if is_enabled else "❌"
+ net_escaped = escape_markdown_v2(net)
+ networks_display.append(f" {status} `{net_escaped}`")
+
+ networks_text = "\n".join(networks_display) if networks_display else "_No networks available_"
+
+ message_text = (
+ header +
+ f"{chain_icon} *Chain:* {chain_escaped}\n\n"
+ f"*Address:*\n`{addr_escaped}`\n\n"
+ f"*Enabled Networks:*\n{networks_text}\n\n"
+ "_Only enabled networks will be queried for balances\\._"
+ )
+
+ # Find wallet index in the list
+ wallet_list = context.user_data.get('wallet_list', [])
+ wallet_idx = None
+ for idx, w in enumerate(wallet_list):
+ if w['address'] == address and w['chain'] == chain:
+ wallet_idx = idx
+ break
+
+ if wallet_idx is not None:
+ keyboard = [
+ [InlineKeyboardButton("🌐 Edit Networks", callback_data=f"gateway_wallet_networks_{wallet_idx}")],
+ [InlineKeyboardButton("🗑️ Delete Wallet", callback_data=f"gateway_wallet_delete_{wallet_idx}")],
+ [InlineKeyboardButton("« Back to Wallets", callback_data="gateway_wallets")]
+ ]
+ else:
+ keyboard = [[InlineKeyboardButton("« Back to Wallets", callback_data="gateway_wallets")]]
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ await query.answer()
+
+ except Exception as e:
+ logger.error(f"Error showing wallet details: {e}", exc_info=True)
+ error_text = f"❌ Error: {escape_markdown_v2(str(e))}"
+ keyboard = [[InlineKeyboardButton("« Back", callback_data="gateway_wallets")]]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+ await query.message.edit_text(error_text, parse_mode="MarkdownV2", reply_markup=reply_markup)
+
+
+async def show_wallet_network_edit(query, context: ContextTypes.DEFAULT_TYPE, chain: str, address: str, wallet_idx: int) -> None:
+ """Show network toggle interface for a wallet"""
+ try:
+ chat_id = query.message.chat_id
+ header, server_online, gateway_running = await build_config_message_header(
+ "🌐 Edit Networks",
+ include_gateway=True,
+ chat_id=chat_id
+ )
+
+ chain_escaped = escape_markdown_v2(chain.title())
+
+ # Get currently enabled networks
+ enabled_networks = get_wallet_networks(context.user_data, address)
+ if enabled_networks is None:
+ enabled_networks = get_default_networks_for_chain(chain)
+
+ # Store current selection in temp context for toggling
+ context.user_data['editing_wallet_networks'] = {
+ 'chain': chain,
+ 'address': address,
+ 'wallet_idx': wallet_idx,
+ 'enabled': list(enabled_networks) # Make a copy
+ }
+
+ display_addr = address[:8] + "..." + address[-6:] if len(address) > 18 else address
+ addr_escaped = escape_markdown_v2(display_addr)
+
+ message_text = (
+ header +
+ f"*Editing Networks for {chain_escaped}*\n"
+ f"`{addr_escaped}`\n\n"
+ "_Toggle networks on/off\\. Only enabled networks will be queried for balances\\._"
+ )
+
+ # Create toggle buttons for each network
+ all_networks = get_all_networks_for_chain(chain)
+ network_buttons = []
+ for net in all_networks:
+ is_enabled = net in enabled_networks
+ status = "✅" if is_enabled else "⬜"
+ # Format network name nicely
+ net_display = net.replace("-", " ").title()
+ button_text = f"{status} {net_display}"
+ network_buttons.append([
+ InlineKeyboardButton(button_text, callback_data=f"gateway_wallet_toggle_net_{wallet_idx}_{net}")
+ ])
+
+ keyboard = network_buttons + [
+ [InlineKeyboardButton("✓ Done", callback_data=f"gateway_wallet_net_done_{wallet_idx}")]
+ ]
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ await query.answer()
+
+ except Exception as e:
+ logger.error(f"Error showing network edit: {e}", exc_info=True)
+ await query.answer(f"❌ Error: {str(e)[:100]}")
+
+
+async def toggle_wallet_network(query, context: ContextTypes.DEFAULT_TYPE, wallet_idx: int, network_id: str) -> None:
+ """Toggle a network on/off for a wallet"""
+ try:
+ editing = context.user_data.get('editing_wallet_networks')
+ if not editing:
+ await query.answer("❌ No wallet being edited")
+ return
+
+ enabled = editing.get('enabled', [])
+ chain = editing['chain']
+ address = editing['address']
+
+ # Toggle the network
+ if network_id in enabled:
+ enabled.remove(network_id)
+ await query.answer(f"❌ {network_id} disabled")
+ else:
+ enabled.append(network_id)
+ await query.answer(f"✅ {network_id} enabled")
+
+ # Update context
+ editing['enabled'] = enabled
+ context.user_data['editing_wallet_networks'] = editing
+
+ # Save to preferences immediately
+ set_wallet_networks(context.user_data, address, enabled)
+
+ # Refresh the edit view
+ await show_wallet_network_edit(query, context, chain, address, wallet_idx)
+
+ except Exception as e:
+ logger.error(f"Error toggling network: {e}", exc_info=True)
+ await query.answer(f"❌ Error: {str(e)[:100]}")
+
+
async def prompt_add_wallet_private_key(query, context: ContextTypes.DEFAULT_TYPE, chain: str) -> None:
"""Prompt user to enter private key for adding wallet"""
try:
+ chat_id = query.message.chat_id
header, server_online, gateway_running = await build_config_message_header(
f"➕ Add {chain.replace('-', ' ').title()} Wallet",
- include_gateway=True
+ include_gateway=True,
+ chat_id=chat_id
)
context.user_data['awaiting_wallet_input'] = 'add_wallet'
@@ -236,7 +501,8 @@ async def prompt_remove_wallet_chain(query, context: ContextTypes.DEFAULT_TYPE)
try:
from servers import server_manager
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
# Get list of gateway wallets
try:
@@ -256,7 +522,8 @@ async def prompt_remove_wallet_chain(query, context: ContextTypes.DEFAULT_TYPE)
header, server_online, gateway_running = await build_config_message_header(
"➖ Remove Wallet",
- include_gateway=True
+ include_gateway=True,
+ chat_id=chat_id
)
message_text = (
@@ -296,7 +563,8 @@ async def prompt_remove_wallet_address(query, context: ContextTypes.DEFAULT_TYPE
try:
from servers import server_manager
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
# Get wallets for this chain
try:
@@ -323,7 +591,8 @@ async def prompt_remove_wallet_address(query, context: ContextTypes.DEFAULT_TYPE
header, server_online, gateway_running = await build_config_message_header(
f"➖ Remove {chain.replace('-', ' ').title()} Wallet",
- include_gateway=True
+ include_gateway=True,
+ chat_id=chat_id
)
chain_escaped = escape_markdown_v2(chain.replace("-", " ").title())
@@ -370,11 +639,15 @@ async def remove_wallet(query, context: ContextTypes.DEFAULT_TYPE, chain: str, a
await query.answer("Removing wallet...")
- client = await server_manager.get_default_client()
+ chat_id = query.message.chat_id
+ client = await server_manager.get_client_for_chat(chat_id)
- # Remove the wallet
+ # Remove the wallet from Gateway
await client.accounts.remove_gateway_wallet(chain=chain, address=address)
+ # Also remove network preferences for this wallet
+ remove_wallet_networks(context.user_data, address)
+
# Show success message
chain_escaped = escape_markdown_v2(chain.replace("-", " ").title())
display_addr = address[:10] + "..." + address[-8:] if len(address) > 20 else address
@@ -445,7 +718,7 @@ async def handle_wallet_input(update: Update, context: ContextTypes.DEFAULT_TYPE
)
try:
- client = await server_manager.get_default_client()
+ client = await server_manager.get_client_for_chat(chat_id)
# Add the wallet
response = await client.accounts.add_gateway_wallet(chain=chain, private_key=private_key)
@@ -453,51 +726,60 @@ async def handle_wallet_input(update: Update, context: ContextTypes.DEFAULT_TYPE
# Extract address from response
address = response.get('address', 'Added') if isinstance(response, dict) else 'Added'
- # Show success message
+ # Set default networks for the new wallet
+ default_networks = get_default_networks_for_chain(chain)
+ set_wallet_networks(context.user_data, address, default_networks)
+
+ # Store info for network selection flow
+ context.user_data['new_wallet_chain'] = chain
+ context.user_data['new_wallet_address'] = address
+ context.user_data['new_wallet_networks'] = list(default_networks)
+ context.user_data['new_wallet_message_id'] = message_id
+ context.user_data['new_wallet_chat_id'] = chat_id
+
+ # Show success message with network selection prompt
display_addr = address[:10] + "..." + address[-8:] if len(address) > 20 else address
addr_escaped = escape_markdown_v2(display_addr)
- success_text = f"✅ *Wallet Added Successfully*\n\n`{addr_escaped}`\n\nAdded to {chain_escaped}"
+ # Build network selection message
+ all_networks = get_all_networks_for_chain(chain)
+ network_buttons = []
+ for net in all_networks:
+ is_enabled = net in default_networks
+ status = "✅" if is_enabled else "⬜"
+ net_display = net.replace("-", " ").title()
+ button_text = f"{status} {net_display}"
+ network_buttons.append([
+ InlineKeyboardButton(button_text, callback_data=f"gateway_wallet_new_toggle_{net}")
+ ])
+
+ success_text = (
+ f"✅ *Wallet Added Successfully*\n\n"
+ f"`{addr_escaped}`\n\n"
+ f"*Select Networks:*\n"
+ f"_Choose which networks to enable for balance queries\\._"
+ )
+
+ keyboard = network_buttons + [
+ [InlineKeyboardButton("✓ Done", callback_data="gateway_wallet_new_net_done")]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
if message_id and chat_id:
await update.get_bot().edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=success_text,
- parse_mode="MarkdownV2"
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
)
else:
await update.get_bot().send_message(
chat_id=chat_id,
text=success_text,
- parse_mode="MarkdownV2"
- )
-
- # Wait a moment then refresh wallets menu
- import asyncio
- await asyncio.sleep(1.5)
-
- # Create mock query object to reuse show_wallets_menu
- async def mock_answer(text=""):
- pass
-
- mock_message = SimpleNamespace(
- edit_text=lambda text, parse_mode=None, reply_markup=None: update.get_bot().edit_message_text(
- chat_id=chat_id,
- message_id=message_id,
- text=text,
- parse_mode=parse_mode,
+ parse_mode="MarkdownV2",
reply_markup=reply_markup
- ),
- chat_id=chat_id,
- message_id=message_id
- )
- mock_query = SimpleNamespace(
- message=mock_message,
- answer=mock_answer
- )
-
- await show_wallets_menu(mock_query, context)
+ )
except Exception as e:
logger.error(f"Error adding wallet: {e}", exc_info=True)
@@ -520,3 +802,111 @@ async def mock_answer(text=""):
except Exception as e:
logger.error(f"Error handling wallet input: {e}", exc_info=True)
context.user_data.pop('awaiting_wallet_input', None)
+
+
+async def show_new_wallet_network_selection(query, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show network selection for newly added wallet"""
+ try:
+ chain = context.user_data.get('new_wallet_chain')
+ address = context.user_data.get('new_wallet_address')
+ enabled_networks = context.user_data.get('new_wallet_networks', [])
+
+ if not chain or not address:
+ await query.answer("❌ No new wallet found")
+ await show_wallets_menu(query, context)
+ return
+
+ display_addr = address[:10] + "..." + address[-8:] if len(address) > 20 else address
+ addr_escaped = escape_markdown_v2(display_addr)
+
+ # Build network selection message
+ all_networks = get_all_networks_for_chain(chain)
+ network_buttons = []
+ for net in all_networks:
+ is_enabled = net in enabled_networks
+ status = "✅" if is_enabled else "⬜"
+ net_display = net.replace("-", " ").title()
+ button_text = f"{status} {net_display}"
+ network_buttons.append([
+ InlineKeyboardButton(button_text, callback_data=f"gateway_wallet_new_toggle_{net}")
+ ])
+
+ message_text = (
+ f"✅ *Wallet Added Successfully*\n\n"
+ f"`{addr_escaped}`\n\n"
+ f"*Select Networks:*\n"
+ f"_Choose which networks to enable for balance queries\\._"
+ )
+
+ keyboard = network_buttons + [
+ [InlineKeyboardButton("✓ Done", callback_data="gateway_wallet_new_net_done")]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ await query.answer()
+
+ except Exception as e:
+ logger.error(f"Error showing new wallet network selection: {e}", exc_info=True)
+ await query.answer(f"❌ Error: {str(e)[:100]}")
+
+
+async def toggle_new_wallet_network(query, context: ContextTypes.DEFAULT_TYPE, network_id: str) -> None:
+ """Toggle a network for newly added wallet"""
+ try:
+ chain = context.user_data.get('new_wallet_chain')
+ address = context.user_data.get('new_wallet_address')
+ enabled_networks = context.user_data.get('new_wallet_networks', [])
+
+ if not chain or not address:
+ await query.answer("❌ No new wallet found")
+ return
+
+ # Toggle the network
+ if network_id in enabled_networks:
+ enabled_networks.remove(network_id)
+ await query.answer(f"❌ {network_id} disabled")
+ else:
+ enabled_networks.append(network_id)
+ await query.answer(f"✅ {network_id} enabled")
+
+ # Update context and preferences
+ context.user_data['new_wallet_networks'] = enabled_networks
+ set_wallet_networks(context.user_data, address, enabled_networks)
+
+ # Refresh the selection view
+ await show_new_wallet_network_selection(query, context)
+
+ except Exception as e:
+ logger.error(f"Error toggling new wallet network: {e}", exc_info=True)
+ await query.answer(f"❌ Error: {str(e)[:100]}")
+
+
+async def finish_new_wallet_network_selection(query, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Finish network selection for newly added wallet and go to wallets menu"""
+ try:
+ address = context.user_data.get('new_wallet_address')
+ enabled_networks = context.user_data.get('new_wallet_networks', [])
+
+ # Save final network selection
+ if address and enabled_networks:
+ set_wallet_networks(context.user_data, address, enabled_networks)
+
+ # Clear temp context
+ context.user_data.pop('new_wallet_chain', None)
+ context.user_data.pop('new_wallet_address', None)
+ context.user_data.pop('new_wallet_networks', None)
+ context.user_data.pop('new_wallet_message_id', None)
+ context.user_data.pop('new_wallet_chat_id', None)
+
+ await query.answer("✅ Network configuration saved")
+ await show_wallets_menu(query, context)
+
+ except Exception as e:
+ logger.error(f"Error finishing network selection: {e}", exc_info=True)
+ await query.answer(f"❌ Error: {str(e)[:100]}")
+ await show_wallets_menu(query, context)
diff --git a/handlers/config/server_context.py b/handlers/config/server_context.py
index 946b2ee..f39d0de 100644
--- a/handlers/config/server_context.py
+++ b/handlers/config/server_context.py
@@ -11,10 +11,13 @@
logger = logging.getLogger(__name__)
-async def get_server_context_header() -> Tuple[str, bool]:
+async def get_server_context_header(chat_id: int = None) -> Tuple[str, bool]:
"""
Get a standardized server context header showing current server and status.
+ Args:
+ chat_id: Optional chat ID to get per-chat server. If None, uses global default.
+
Returns:
Tuple of (header_text: str, is_online: bool)
header_text: Formatted markdown text with server info and status
@@ -23,8 +26,11 @@ async def get_server_context_header() -> Tuple[str, bool]:
try:
from servers import server_manager
- # Get default server
- default_server = server_manager.get_default_server()
+ # Get default server (per-chat if chat_id provided)
+ if chat_id is not None:
+ default_server = server_manager.get_default_server_for_chat(chat_id)
+ else:
+ default_server = server_manager.get_default_server()
servers = server_manager.list_servers()
if not servers:
@@ -73,10 +79,13 @@ async def get_server_context_header() -> Tuple[str, bool]:
return f"⚠️ _Error loading server info: {escape_markdown_v2(str(e))}_\n", False
-async def get_gateway_status_info() -> Tuple[str, bool]:
+async def get_gateway_status_info(chat_id: int = None) -> Tuple[str, bool]:
"""
Get gateway status information for the current server.
+ Args:
+ chat_id: Optional chat ID to get per-chat server. If None, uses global default.
+
Returns:
Tuple of (status_text: str, is_running: bool)
status_text: Formatted markdown text with gateway status
@@ -85,7 +94,10 @@ async def get_gateway_status_info() -> Tuple[str, bool]:
try:
from servers import server_manager
- client = await server_manager.get_default_client()
+ if chat_id is not None:
+ client = await server_manager.get_client_for_chat(chat_id)
+ else:
+ client = await server_manager.get_default_client()
# Check gateway status
try:
@@ -118,7 +130,8 @@ async def get_gateway_status_info() -> Tuple[str, bool]:
async def build_config_message_header(
title: str,
- include_gateway: bool = False
+ include_gateway: bool = False,
+ chat_id: int = None
) -> Tuple[str, bool, bool]:
"""
Build a standardized header for configuration messages.
@@ -126,6 +139,7 @@ async def build_config_message_header(
Args:
title: The title/heading for this config screen (will be bolded automatically)
include_gateway: Whether to include gateway status info
+ chat_id: Optional chat ID to get per-chat server. If None, uses global default.
Returns:
Tuple of (header_text: str, server_online: bool, gateway_running: bool)
@@ -135,14 +149,18 @@ async def build_config_message_header(
header = f"*{title_escaped}*\n\n"
# Add server context
- server_context, server_online = await get_server_context_header()
+ server_context, server_online = await get_server_context_header(chat_id)
header += server_context
- # Add gateway status if requested
+ # Add gateway status if requested (but only if server is online to avoid long timeouts)
gateway_running = False
if include_gateway:
- gateway_info, gateway_running = await get_gateway_status_info()
- header += gateway_info
+ if server_online:
+ gateway_info, gateway_running = await get_gateway_status_info(chat_id)
+ header += gateway_info
+ else:
+ # Server is offline, skip gateway check to avoid timeout
+ header += f"*Gateway:* ⚪️ {escape_markdown_v2('N/A')}\n"
header += "\n"
diff --git a/handlers/config/servers.py b/handlers/config/servers.py
index 2718aa8..0f66e18 100644
--- a/handlers/config/servers.py
+++ b/handlers/config/servers.py
@@ -42,7 +42,9 @@ async def show_api_servers(query, context: ContextTypes.DEFAULT_TYPE) -> None:
await server_manager.reload_config()
servers = server_manager.list_servers()
- default_server = server_manager.get_default_server()
+ chat_id = query.message.chat_id
+ # Use per-chat default if set, otherwise global default
+ default_server = server_manager.get_default_server_for_chat(chat_id)
if not servers:
message_text = (
@@ -184,8 +186,11 @@ async def show_server_details(query, context: ContextTypes.DEFAULT_TYPE, server_
await query.answer("❌ Server not found")
return
+ chat_id = query.message.chat_id
+ chat_info = server_manager.get_chat_server_info(chat_id)
default_server = server_manager.get_default_server()
- is_default = server_name == default_server
+ is_global_default = server_name == default_server
+ is_chat_default = chat_info.get("is_per_chat") and chat_info.get("server") == server_name
# Check status
status_result = await server_manager.check_server_status(server_name)
@@ -214,14 +219,16 @@ async def show_server_details(query, context: ContextTypes.DEFAULT_TYPE, server_
f"*Username:* `{username_escaped}`\n"
)
- if is_default:
- message_text += "\n⭐️ _This is the default server_"
+ # Show if this is the default for this chat
+ if is_chat_default:
+ message_text += "\n⭐️ _Default for this chat_"
message_text += "\n\n_You can modify or delete this server using the buttons below\\._"
keyboard = []
- if not is_default:
+ # Show Set as Default button only if not already default
+ if not is_chat_default:
keyboard.append([InlineKeyboardButton("⭐️ Set as Default", callback_data=f"api_server_set_default_{server_name}")])
# Add modification buttons in a row with 4 columns
@@ -251,14 +258,25 @@ async def show_server_details(query, context: ContextTypes.DEFAULT_TYPE, server_
async def set_default_server(query, context: ContextTypes.DEFAULT_TYPE, server_name: str) -> None:
- """Set a server as the default"""
+ """Set server as default for this chat"""
try:
from servers import server_manager
+ from handlers.dex._shared import invalidate_cache
- success = server_manager.set_default_server(server_name)
+ chat_id = query.message.chat_id
+ success = server_manager.set_default_server_for_chat(chat_id, server_name)
if success:
- await query.answer(f"✅ Set {server_name} as default")
+ # Invalidate ALL cached data since we're switching to a different server
+ # This ensures /lp, /swap, etc. will fetch fresh data from the new server
+ invalidate_cache(context.user_data, "all")
+
+ # Store current server in user_data as fallback for background tasks
+ context.user_data["_current_server"] = server_name
+
+ logger.info(f"Cache invalidated after switching to server '{server_name}'")
+
+ await query.answer(f"✅ Set {server_name} as default for this chat")
await show_server_details(query, context, server_name)
else:
await query.answer("❌ Failed to set default server")
@@ -295,10 +313,21 @@ async def delete_server(query, context: ContextTypes.DEFAULT_TYPE, server_name:
"""Delete a server from configuration"""
try:
from servers import server_manager
+ from handlers.dex._shared import invalidate_cache
+
+ # Check if this is the current chat's default server
+ chat_id = query.message.chat_id
+ current_default = server_manager.get_default_server_for_chat(chat_id)
+ was_current = (current_default == server_name)
success = server_manager.delete_server(server_name)
if success:
+ # Invalidate cache if we deleted the server that was in use
+ if was_current:
+ invalidate_cache(context.user_data, "all")
+ logger.info(f"Cache invalidated after deleting current server '{server_name}'")
+
await query.answer(f"✅ Deleted {server_name}")
await show_api_servers(query, context)
else:
@@ -865,6 +894,13 @@ async def handle_modify_value_input(update: Update, context: ContextTypes.DEFAUL
if success:
logger.info(f"Successfully modified {field} for server {server_name}")
+
+ # Invalidate cache if this is the current chat's default server
+ current_default = server_manager.get_default_server_for_chat(chat_id)
+ if current_default == server_name:
+ from handlers.dex._shared import invalidate_cache
+ invalidate_cache(context.user_data, "all")
+ logger.info(f"Cache invalidated after modifying current server '{server_name}'")
if modify_message_id and modify_chat_id:
logger.info(f"Attempting to show server details for message {modify_message_id}")
diff --git a/handlers/config/user_preferences.py b/handlers/config/user_preferences.py
index 7cd4843..caccab6 100644
--- a/handlers/config/user_preferences.py
+++ b/handlers/config/user_preferences.py
@@ -104,11 +104,26 @@ class GeneralPrefs(TypedDict, total=False):
active_server: Optional[str]
+class WalletNetworkPrefs(TypedDict, total=False):
+ """Network preferences for a specific wallet.
+
+ Keys are wallet addresses, values are lists of enabled network IDs.
+ Example: {"0x1234...": ["ethereum-mainnet", "base", "arbitrum"]}
+ """
+ pass # Dynamic keys based on wallet addresses
+
+
+class GatewayPrefs(TypedDict, total=False):
+ """Gateway-related preferences including wallet network settings."""
+ wallet_networks: Dict[str, list] # wallet_address -> list of enabled network IDs
+
+
class UserPreferences(TypedDict, total=False):
portfolio: PortfolioPrefs
clob: CLOBPrefs
dex: DEXPrefs
general: GeneralPrefs
+ gateway: GatewayPrefs
# ============================================
@@ -138,6 +153,9 @@ def _get_default_preferences() -> UserPreferences:
"general": {
"active_server": None,
},
+ "gateway": {
+ "wallet_networks": {}, # wallet_address -> list of enabled network IDs
+ },
}
@@ -469,6 +487,140 @@ def set_active_server(user_data: Dict, server_name: Optional[str]) -> None:
logger.info(f"Set active server to {server_name}")
+# ============================================
+# PUBLIC API - GATEWAY / WALLET NETWORKS
+# ============================================
+
+# Default networks per chain
+DEFAULT_ETHEREUM_NETWORKS = ["ethereum-mainnet", "base", "arbitrum"]
+DEFAULT_SOLANA_NETWORKS = ["solana-mainnet-beta"]
+
+
+def get_gateway_prefs(user_data: Dict) -> GatewayPrefs:
+ """Get gateway preferences
+
+ Returns:
+ Gateway preferences with wallet_networks
+ """
+ _migrate_legacy_data(user_data)
+ prefs = _ensure_preferences(user_data)
+ return deepcopy(prefs.get("gateway", {"wallet_networks": {}}))
+
+
+def get_wallet_networks(user_data: Dict, wallet_address: str) -> list:
+ """Get enabled networks for a specific wallet
+
+ Args:
+ user_data: User data dict
+ wallet_address: The wallet address
+
+ Returns:
+ List of enabled network IDs, or None if not configured (use defaults)
+ """
+ gateway_prefs = get_gateway_prefs(user_data)
+ wallet_networks = gateway_prefs.get("wallet_networks", {})
+ return wallet_networks.get(wallet_address)
+
+
+def set_wallet_networks(user_data: Dict, wallet_address: str, networks: list) -> None:
+ """Set enabled networks for a specific wallet
+
+ Args:
+ user_data: User data dict
+ wallet_address: The wallet address
+ networks: List of enabled network IDs
+ """
+ prefs = _ensure_preferences(user_data)
+ if "gateway" not in prefs:
+ prefs["gateway"] = {"wallet_networks": {}}
+ if "wallet_networks" not in prefs["gateway"]:
+ prefs["gateway"]["wallet_networks"] = {}
+ prefs["gateway"]["wallet_networks"][wallet_address] = networks
+ logger.info(f"Set wallet {wallet_address[:10]}... networks to {networks}")
+
+
+def remove_wallet_networks(user_data: Dict, wallet_address: str) -> None:
+ """Remove network preferences for a wallet (when wallet is deleted)
+
+ Args:
+ user_data: User data dict
+ wallet_address: The wallet address to remove
+ """
+ prefs = _ensure_preferences(user_data)
+ if "gateway" in prefs and "wallet_networks" in prefs["gateway"]:
+ prefs["gateway"]["wallet_networks"].pop(wallet_address, None)
+ logger.info(f"Removed wallet {wallet_address[:10]}... network preferences")
+
+
+def get_default_networks_for_chain(chain: str) -> list:
+ """Get default networks for a blockchain chain
+
+ Args:
+ chain: The blockchain chain (ethereum, solana)
+
+ Returns:
+ List of default network IDs for the chain
+ """
+ if chain == "ethereum":
+ return DEFAULT_ETHEREUM_NETWORKS.copy()
+ elif chain == "solana":
+ return DEFAULT_SOLANA_NETWORKS.copy()
+ return []
+
+
+def get_all_networks_for_chain(chain: str) -> list:
+ """Get all available networks for a blockchain chain
+
+ Args:
+ chain: The blockchain chain (ethereum, solana)
+
+ Returns:
+ List of all available network IDs for the chain
+ """
+ if chain == "ethereum":
+ return [
+ "ethereum-mainnet",
+ "base",
+ "arbitrum",
+ "polygon",
+ "optimism",
+ "avalanche",
+ ]
+ elif chain == "solana":
+ return [
+ "solana-mainnet-beta",
+ "solana-devnet",
+ ]
+ return []
+
+
+def get_all_enabled_networks(user_data: Dict) -> set:
+ """Get all enabled networks across all configured wallets.
+
+ This aggregates networks from all wallet configurations.
+ If no wallets are configured, returns None (meaning no filtering).
+
+ Args:
+ user_data: User data dict
+
+ Returns:
+ Set of enabled network IDs, or None if no wallets configured
+ """
+ gateway_prefs = get_gateway_prefs(user_data)
+ wallet_networks = gateway_prefs.get("wallet_networks", {})
+
+ if not wallet_networks:
+ return None # No wallets configured, don't filter
+
+ # Aggregate all enabled networks from all wallets
+ all_networks = set()
+ for networks in wallet_networks.values():
+ if networks:
+ all_networks.update(networks)
+
+ return all_networks if all_networks else None
+
+
# ============================================
# UTILITY FUNCTIONS
# ============================================
diff --git a/handlers/dex/__init__.py b/handlers/dex/__init__.py
index 1752e80..4f2951e 100644
--- a/handlers/dex/__init__.py
+++ b/handlers/dex/__init__.py
@@ -515,11 +515,15 @@ async def dex_callback_handler(update: Update, context: ContextTypes.DEFAULT_TYP
# Pool OHLCV and combined chart handlers (for Meteora/CLMM pools)
elif action.startswith("pool_ohlcv:"):
- timeframe = action.split(":")[1]
- await handle_pool_ohlcv(update, context, timeframe)
+ parts = action.split(":")
+ timeframe = parts[1]
+ currency = parts[2] if len(parts) > 2 else "usd"
+ await handle_pool_ohlcv(update, context, timeframe, currency)
elif action.startswith("pool_combined:"):
- timeframe = action.split(":")[1]
- await handle_pool_combined_chart(update, context, timeframe)
+ parts = action.split(":")
+ timeframe = parts[1]
+ currency = parts[2] if len(parts) > 2 else "usd"
+ await handle_pool_combined_chart(update, context, timeframe, currency)
# Refresh data
elif action == "refresh":
diff --git a/handlers/dex/_shared.py b/handlers/dex/_shared.py
index 184cee1..63cc7ec 100644
--- a/handlers/dex/_shared.py
+++ b/handlers/dex/_shared.py
@@ -130,7 +130,7 @@ async def cached_call(
# Define which cache keys should be invalidated together
CACHE_GROUPS = {
"balances": ["gateway_balances", "portfolio_data", "wallet_balances", "token_balances", "gateway_data"],
- "positions": ["clmm_positions", "liquidity_positions", "pool_positions", "gateway_lp_positions"],
+ "positions": ["clmm_positions", "liquidity_positions", "pool_positions", "gateway_lp_positions", "gateway_closed_positions"],
"swaps": ["swap_history", "recent_swaps"],
"tokens": ["token_cache"], # Token list from gateway
"all": None, # Special: clears entire cache
@@ -216,6 +216,7 @@ def __init__(self):
self._tasks: Dict[int, asyncio.Task] = {}
self._last_activity: Dict[int, float] = {}
self._refresh_funcs: Dict[str, Callable] = {}
+ self._user_chat_ids: Dict[int, int] = {} # Track chat_id per user for server selection
def register_refresh(self, key: str, func: Callable) -> None:
"""Register a function to be called during background refresh.
@@ -227,7 +228,7 @@ def register_refresh(self, key: str, func: Callable) -> None:
self._refresh_funcs[key] = func
logger.debug(f"Registered background refresh for '{key}'")
- def touch(self, user_id: int, user_data: dict) -> None:
+ def touch(self, user_id: int, user_data: dict, chat_id: int = None) -> None:
"""Mark user as active, starting background refresh if needed.
Call this at the start of any handler to keep refresh alive.
@@ -235,9 +236,14 @@ def touch(self, user_id: int, user_data: dict) -> None:
Args:
user_id: Telegram user ID
user_data: context.user_data dict
+ chat_id: Chat ID for per-chat server selection
"""
self._last_activity[user_id] = time.time()
+ # Store chat_id for this user (for per-chat server selection)
+ if chat_id is not None:
+ self._user_chat_ids[user_id] = chat_id
+
if user_id not in self._tasks or self._tasks[user_id].done():
self._tasks[user_id] = asyncio.create_task(
self._refresh_loop(user_id, user_data)
@@ -247,7 +253,9 @@ def touch(self, user_id: int, user_data: dict) -> None:
async def _refresh_loop(self, user_id: int, user_data: dict) -> None:
"""Background loop that refreshes data until inactivity timeout."""
try:
- client = await get_client()
+ # Use per-chat server if available
+ chat_id = self._user_chat_ids.get(user_id)
+ client = await get_client(chat_id)
except Exception as e:
logger.warning(f"Background refresh: couldn't get client: {e}")
return
@@ -273,6 +281,7 @@ async def _refresh_loop(self, user_id: int, user_data: dict) -> None:
# Cleanup
self._tasks.pop(user_id, None)
self._last_activity.pop(user_id, None)
+ self._user_chat_ids.pop(user_id, None)
def stop(self, user_id: int) -> None:
"""Manually stop background refresh for a user."""
@@ -280,6 +289,7 @@ def stop(self, user_id: int) -> None:
self._tasks[user_id].cancel()
self._tasks.pop(user_id, None)
self._last_activity.pop(user_id, None)
+ self._user_chat_ids.pop(user_id, None)
logger.debug(f"Manually stopped background refresh for user {user_id}")
@@ -298,9 +308,11 @@ async def my_handler(update, context):
@functools.wraps(func)
async def wrapper(update, context, *args, **kwargs):
if update.effective_user:
+ chat_id = update.effective_chat.id if update.effective_chat else None
background_refresh.touch(
update.effective_user.id,
- context.user_data
+ context.user_data,
+ chat_id=chat_id
)
return await func(update, context, *args, **kwargs)
return wrapper
diff --git a/handlers/dex/geckoterminal.py b/handlers/dex/geckoterminal.py
index 7af878f..b54020e 100644
--- a/handlers/dex/geckoterminal.py
+++ b/handlers/dex/geckoterminal.py
@@ -1400,7 +1400,7 @@ async def show_pool_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, p
])
# Handle case when returning from photo (OHLCV chart) - can't edit photo to text
- if query.message.photo:
+ if getattr(query.message, 'photo', None):
await query.message.delete()
await query.message.chat.send_message(
"\n".join(lines),
@@ -1470,7 +1470,7 @@ async def show_gecko_charts_menu(update: Update, context: ContextTypes.DEFAULT_T
])
# Handle photo messages - can't edit photo to text
- if query.message.photo:
+ if getattr(query.message, 'photo', None):
await query.message.delete()
await query.message.chat.send_message(
"\n".join(lines),
@@ -1501,7 +1501,7 @@ async def show_ohlcv_chart(update: Update, context: ContextTypes.DEFAULT_TYPE, t
await query.answer("Loading chart...")
# Show loading - handle photo messages (can't edit photo to text)
- if query.message.photo:
+ if getattr(query.message, 'photo', None):
await query.message.delete()
loading_msg = await query.message.chat.send_message(
f"📈 *OHLCV Chart*\n\n_Loading {timeframe} data\\.\\.\\._",
@@ -1658,12 +1658,12 @@ async def show_ohlcv_chart(update: Update, context: ContextTypes.DEFAULT_TYPE, t
def _format_timeframe_label(timeframe: str) -> str:
"""Convert API timeframe to display label"""
labels = {
- "1m": "1 Hour (1m candles)",
- "5m": "5 Hours (5m candles)",
- "15m": "15 Hours (15m candles)",
- "1h": "1 Day (1h candles)",
- "4h": "4 Days (4h candles)",
- "1d": "7 Days (1d candles)",
+ "1m": "1m candles",
+ "5m": "5m candles",
+ "15m": "15m candles",
+ "1h": "1h candles",
+ "4h": "4h candles",
+ "1d": "1d candles",
}
return labels.get(timeframe, timeframe)
@@ -1691,7 +1691,7 @@ async def show_gecko_liquidity(update: Update, context: ContextTypes.DEFAULT_TYP
await query.answer("Loading liquidity chart...")
# Show loading
- if query.message.photo:
+ if getattr(query.message, 'photo', None):
await query.message.delete()
loading_msg = await query.message.chat.send_message(
f"📊 *Liquidity Distribution*\n\n_Loading\\.\\.\\._",
@@ -1709,10 +1709,12 @@ async def show_gecko_liquidity(update: Update, context: ContextTypes.DEFAULT_TYP
connector = get_connector_for_dex(dex_id)
# Fetch liquidity bins via gateway
+ chat_id = update.effective_chat.id
bins, pool_info, error = await fetch_liquidity_bins(
pool_address=address,
connector=connector,
- user_data=context.user_data
+ user_data=context.user_data,
+ chat_id=chat_id
)
if error or not bins:
@@ -1805,7 +1807,7 @@ async def show_gecko_combined(update: Update, context: ContextTypes.DEFAULT_TYPE
# Show loading - keep the message reference for editing
loading_msg = query.message
- if query.message.photo:
+ if getattr(query.message, 'photo', None):
# Edit photo caption to show loading (keeps the existing photo)
try:
await query.message.edit_caption(
@@ -1846,10 +1848,12 @@ async def show_gecko_combined(update: Update, context: ContextTypes.DEFAULT_TYPE
ohlcv_data = ohlcv_result.get("data", {}).get("attributes", {}).get("ohlcv_list", [])
# Fetch liquidity bins
+ chat_id = update.effective_chat.id
bins, pool_info, _ = await fetch_liquidity_bins(
pool_address=address,
connector=connector,
- user_data=context.user_data
+ user_data=context.user_data,
+ chat_id=chat_id
)
if not ohlcv_data and not bins:
@@ -2557,7 +2561,7 @@ async def handle_gecko_add_liquidity(update: Update, context: ContextTypes.DEFAU
# Delete the current message and show the pool detail with add liquidity controls
try:
- if query.message.photo:
+ if getattr(query.message, 'photo', None):
await query.message.delete()
else:
await query.message.delete()
diff --git a/handlers/dex/liquidity.py b/handlers/dex/liquidity.py
index d56d419..ac000b6 100644
--- a/handlers/dex/liquidity.py
+++ b/handlers/dex/liquidity.py
@@ -13,6 +13,7 @@
from telegram.ext import ContextTypes
from utils.telegram_formatters import escape_markdown_v2, format_error_message, resolve_token_symbol, format_amount, KNOWN_TOKENS
+from utils.auth import gateway_required
from servers import get_client
from ._shared import (
get_cached,
@@ -95,6 +96,7 @@ async def _fetch_gateway_balances(client) -> dict:
data = {
"balances_by_network": defaultdict(list),
"total_value": 0,
+ "token_prices": {}, # token symbol -> USD price
}
try:
@@ -119,13 +121,18 @@ async def _fetch_gateway_balances(client) -> dict:
token = balance.get("token", "???")
units = balance.get("units", 0)
value = balance.get("value", 0)
+ price = balance.get("price", 0)
if value > 0.01:
data["balances_by_network"][network].append({
"token": token,
"units": units,
- "value": value
+ "value": value,
+ "price": price
})
data["total_value"] += value
+ # Store token price for PnL conversion
+ if token and price:
+ data["token_prices"][token] = price
# Sort by value
for network in data["balances_by_network"]:
@@ -153,7 +160,8 @@ async def _fetch_lp_positions(client, status: str = "OPEN") -> dict:
result = await client.gateway_clmm.search_positions(
limit=100,
offset=0,
- status=status
+ status=status,
+ refresh=True
)
if not result:
@@ -217,12 +225,16 @@ def is_active_with_liquidity(pos):
return data
-def _format_compact_position_line(pos: dict, token_cache: dict = None, index: int = None) -> str:
+def _format_compact_position_line(pos: dict, token_cache: dict = None, index: int = None, token_prices: dict = None) -> str:
"""Format a single position as a compact line for display
- Returns: "1. SOL-USDC (meteora) 🟢 [0.89-1.47] | 10.5 SOL / 123 USDC"
+ Returns: "1. SOL-USDC (meteora) 🟢 [0.89-1.47] | PnL: -$25 | Value: $63"
+
+ Args:
+ token_prices: dict mapping token symbol -> USD price (e.g. {"SOL": 138.82})
"""
token_cache = token_cache or {}
+ token_prices = token_prices or {}
# Resolve token symbols
base_token = pos.get('base_token', pos.get('token_a', ''))
@@ -241,16 +253,43 @@ def _format_compact_position_line(pos: dict, token_cache: dict = None, index: in
in_range = pos.get('in_range', '')
status_emoji = "🟢" if in_range == "IN_RANGE" else "🔴" if in_range == "OUT_OF_RANGE" else "⚪"
- # Format range
+ # Format range with enough decimals to show the full price
+ lower = pos.get('lower_price', pos.get('price_lower', ''))
+ upper = pos.get('upper_price', pos.get('price_upper', ''))
+ current = pos.get('current_price', '')
+
range_str = ""
+ price_indicator = ""
if lower and upper:
try:
lower_f = float(lower)
upper_f = float(upper)
+
+ # Determine decimal places needed based on magnitude
if lower_f >= 1:
- range_str = f"[{lower_f:.2f}-{upper_f:.2f}]"
+ decimals = 2
+ elif lower_f >= 0.001:
+ decimals = 6
else:
- range_str = f"[{lower_f:.4f}-{upper_f:.4f}]"
+ decimals = 8
+
+ range_str = f"[{lower_f:.{decimals}f}-{upper_f:.{decimals}f}]"
+
+ # Add price position indicator if we have current price
+ if current:
+ current_f = float(current)
+ if current_f < lower_f:
+ # Price below range - show how far below
+ price_indicator = "▼" # Below range
+ elif current_f > upper_f:
+ # Price above range
+ price_indicator = "▲" # Above range
+ else:
+ # In range - show position with bar
+ pct = (current_f - lower_f) / (upper_f - lower_f)
+ bar_len = 5
+ filled = int(pct * bar_len)
+ price_indicator = f"[{'█' * filled}{'░' * (bar_len - filled)}]"
except (ValueError, TypeError):
range_str = f"[{lower}-{upper}]"
@@ -258,45 +297,98 @@ def _format_compact_position_line(pos: dict, token_cache: dict = None, index: in
base_amount = pos.get('base_token_amount', pos.get('amount_a', pos.get('token_a_amount', 0)))
quote_amount = pos.get('quote_token_amount', pos.get('amount_b', pos.get('token_b_amount', 0)))
- # Build line
- prefix = f"{index}. " if index is not None else "• "
- line = f"{prefix}{pair} ({connector}) {status_emoji} {range_str}"
+ # Get position value from pnl_summary
+ pnl_summary = pos.get('pnl_summary', {})
+ position_value_quote = pnl_summary.get('current_total_value_quote')
- # Add amounts if available
- try:
- base_amt = float(base_amount) if base_amount else 0
- quote_amt = float(quote_amount) if quote_amount else 0
- if base_amt > 0 or quote_amt > 0:
- line += f"\n 💰 {_format_token_amount(base_amt)} {base_symbol} / {_format_token_amount(quote_amt)} {quote_symbol}"
- except (ValueError, TypeError):
- pass
+ # Get values from pnl_summary (all values are in quote token units)
+ total_pnl_quote = pnl_summary.get('total_pnl_quote', 0)
+ current_lp_value_quote = pnl_summary.get('current_lp_value_quote', 0)
+
+ # Get PENDING fees (fees available to collect) and COLLECTED fees
+ base_fee_pending = pos.get('base_fee_pending', 0) or 0
+ quote_fee_pending = pos.get('quote_fee_pending', 0) or 0
+ base_fee_collected = pos.get('base_fee_collected', 0) or 0
+ quote_fee_collected = pos.get('quote_fee_collected', 0) or 0
- # Add pending fees if any
- base_fee = pos.get('base_fee_pending', pos.get('unclaimed_fee_a', 0))
- quote_fee = pos.get('quote_fee_pending', pos.get('unclaimed_fee_b', 0))
+ # Build line with price indicator next to range
+ prefix = f"{index}. " if index is not None else "• "
+ range_with_indicator = f"{range_str} {price_indicator}" if price_indicator else range_str
+ line = f"{prefix}{pair} ({connector}) {status_emoji} {range_with_indicator}"
+
+ # Add PnL + value + pending fees, converted to USD
try:
- base_fee_f = float(base_fee) if base_fee else 0
- quote_fee_f = float(quote_fee) if quote_fee else 0
- if base_fee_f > 0 or quote_fee_f > 0:
- line += f"\n 🎁 Fees: {_format_token_amount(base_fee_f)} {base_symbol} / {_format_token_amount(quote_fee_f)} {quote_symbol}"
+ pnl_f = float(total_pnl_quote) if total_pnl_quote else 0
+ lp_value_f = float(current_lp_value_quote) if current_lp_value_quote else 0
+
+ # Get token prices for USD conversion (try exact match, then variants)
+ def get_price(symbol, default=0):
+ if symbol in token_prices:
+ return token_prices[symbol]
+ # Try case-insensitive match
+ symbol_lower = symbol.lower()
+ for key, price in token_prices.items():
+ if key.lower() == symbol_lower:
+ return price
+ # Try common variants (WSOL <-> SOL, WETH <-> ETH, etc.)
+ variants = {
+ "sol": ["wsol", "wrapped sol"],
+ "wsol": ["sol"],
+ "eth": ["weth", "wrapped eth"],
+ "weth": ["eth"],
+ }
+ for variant in variants.get(symbol_lower, []):
+ for key, price in token_prices.items():
+ if key.lower() == variant:
+ return price
+ return default
+
+ quote_price = get_price(quote_symbol, 1.0)
+ base_price = get_price(base_symbol, 0)
+
+ # Convert PnL and value from quote token to USD
+ pnl_usd = pnl_f * quote_price
+ value_usd = lp_value_f * quote_price
+
+ # Calculate pending fees in USD (fees available to collect)
+ base_pending_f = float(base_fee_pending) if base_fee_pending else 0
+ quote_pending_f = float(quote_fee_pending) if quote_fee_pending else 0
+ pending_fees_usd = (base_pending_f * base_price) + (quote_pending_f * quote_price)
+
+ # Calculate collected fees in USD (fees already claimed)
+ base_collected_f = float(base_fee_collected) if base_fee_collected else 0
+ quote_collected_f = float(quote_fee_collected) if quote_fee_collected else 0
+ collected_fees_usd = (base_collected_f * base_price) + (quote_collected_f * quote_price)
+
+ # Debug logging
+ logger.info(f"Position {index}: {base_symbol}@${base_price:.4f}, {quote_symbol}@${quote_price:.2f} | pending=${pending_fees_usd:.2f}, collected=${collected_fees_usd:.2f}")
+
+ if value_usd > 0 or pnl_f != 0:
+ # Format: PnL: -$25.12 | Value: $63.45 | 🎁 $3.70 | 💰 $1.20
+ parts = []
+ if pnl_usd >= 0:
+ parts.append(f"PnL: +${pnl_usd:.2f}")
+ else:
+ parts.append(f"PnL: -${abs(pnl_usd):.2f}")
+ parts.append(f"Value: ${value_usd:.2f}")
+ if pending_fees_usd > 0.01:
+ parts.append(f"🎁 ${pending_fees_usd:.2f}")
+ if collected_fees_usd > 0.01:
+ parts.append(f"💰 ${collected_fees_usd:.2f}")
+ line += "\n " + " | ".join(parts)
except (ValueError, TypeError):
pass
return line
-def _format_closed_position_line(pos: dict, token_cache: dict = None) -> str:
- """Format a closed position as a compact line
+def _format_closed_position_line(pos: dict, token_cache: dict = None, token_prices: dict = None) -> str:
+ """Format a closed position with same format as active positions
- Shows:
- - Pair & connector
- - Price direction: 📈 price went up (ended with more quote), 📉 price went down (ended with more base)
- - Fees earned (actual profit)
- - Age
-
- Returns: "ORE-SOL (met) 📈 Fees: 0.013 ORE 3d"
+ Shows: Pair (connector) ✓ [range] | PnL: +$2.88 | 💰 $1.40 | 1d
"""
token_cache = token_cache or {}
+ token_prices = token_prices or {}
# Resolve token symbols
base_token = pos.get('base_token', pos.get('token_a', ''))
@@ -307,71 +399,70 @@ def _format_closed_position_line(pos: dict, token_cache: dict = None) -> str:
connector = pos.get('connector', 'unknown')[:3]
- # Determine price direction based on position changes
- # If you end with more quote than you started with, price went UP (you sold base for quote)
- # If you end with more base than you started with, price went DOWN (you bought base with quote)
- pnl_summary = pos.get('pnl_summary', {})
- base_pnl = pnl_summary.get('base_pnl', 0) or 0
- quote_pnl = pnl_summary.get('quote_pnl', 0) or 0
-
- try:
- base_pnl_f = float(base_pnl)
- quote_pnl_f = float(quote_pnl)
-
- # If quote increased significantly, price went up
- # If base increased significantly, price went down
- if abs(quote_pnl_f) > 0.001 or abs(base_pnl_f) > 0.001:
- if quote_pnl_f > base_pnl_f:
- direction_emoji = "📈" # Price went up, you have more quote
+ # Get price range
+ lower = pos.get('lower_price', pos.get('price_lower', ''))
+ upper = pos.get('upper_price', pos.get('price_upper', ''))
+ range_str = ""
+ if lower and upper:
+ try:
+ lower_f = float(lower)
+ upper_f = float(upper)
+ if lower_f >= 1:
+ decimals = 2
+ elif lower_f >= 0.001:
+ decimals = 6
else:
- direction_emoji = "📉" # Price went down, you have more base
- else:
- direction_emoji = "➡️" # Price stayed in range
- except (ValueError, TypeError):
- direction_emoji = ""
+ decimals = 8
+ range_str = f"[{lower_f:.{decimals}f}-{upper_f:.{decimals}f}]"
+ except (ValueError, TypeError):
+ pass
- # Fees collected (actual profit!)
- base_fee = pos.get('base_fee_collected', 0) or 0
- quote_fee = pos.get('quote_fee_collected', 0) or 0
- fees_str = ""
+ # Get PnL data - use pre-calculated total_pnl_quote
+ pnl_summary = pos.get('pnl_summary', {})
+ total_pnl_quote = pnl_summary.get('total_pnl_quote', 0) or 0
+ total_fees_value = pnl_summary.get('total_fees_value_quote', 0) or 0
try:
- base_fee_f = float(base_fee)
- quote_fee_f = float(quote_fee)
+ pnl_f = float(total_pnl_quote)
+ fees_f = float(total_fees_value)
+ except (ValueError, TypeError):
+ pnl_f = 0
+ fees_f = 0
- fee_parts = []
- if base_fee_f > 0.0001:
- fee_parts.append(f"{_format_token_amount(base_fee_f)} {base_symbol}")
- if quote_fee_f > 0.0001:
- fee_parts.append(f"{_format_token_amount(quote_fee_f)} {quote_symbol}")
+ # Get quote token price for USD conversion
+ quote_price = token_prices.get(quote_symbol, 1.0)
- if fee_parts:
- fees_str = f"💰 {' + '.join(fee_parts)}"
- else:
- fees_str = "💰 0"
- except (ValueError, TypeError):
- pass
+ # Convert to USD
+ pnl_usd = pnl_f * quote_price
+ fees_usd = fees_f * quote_price
# Get close timestamp
closed_at = pos.get('closed_at', pos.get('updated_at', ''))
age = format_relative_time(closed_at) if closed_at else ""
- # Build line: "ORE-SOL (met) 📈 💰 0.013 ORE 3d"
- parts = [f"{pair} ({connector})"]
- if direction_emoji:
- parts.append(direction_emoji)
- if fees_str:
- parts.append(fees_str)
+ # Build line: "MET-USDC (met) ✓ [0.31-0.32]"
+ line = f"{pair} ({connector}) ✓ {range_str}"
+
+ # Add PnL and fees on second line in USD
+ parts = []
+ if pnl_usd >= 0:
+ parts.append(f"PnL: +${pnl_usd:.2f}")
+ else:
+ parts.append(f"PnL: -${abs(pnl_usd):.2f}")
+ if fees_usd > 0.01:
+ parts.append(f"💰 ${fees_usd:.2f}")
if age:
- parts.append(f" {age}")
+ parts.append(age)
+ line += "\n " + " | ".join(parts)
- return " ".join(parts)
+ return line
# ============================================
# MENU DISPLAY
# ============================================
+@gateway_required
async def handle_liquidity(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle liquidity pools - unified menu"""
context.user_data["dex_state"] = "liquidity"
@@ -387,10 +478,11 @@ async def show_liquidity_menu(update: Update, context: ContextTypes.DEFAULT_TYPE
- Recent closed positions (history)
- Explore pools button
"""
+ chat_id = update.effective_chat.id
help_text = r"💧 *Liquidity Pools*" + "\n\n"
try:
- client = await get_client()
+ client = await get_client(chat_id)
# Fetch balances (cached)
gateway_data = await cached_call(
@@ -401,27 +493,47 @@ async def show_liquidity_menu(update: Update, context: ContextTypes.DEFAULT_TYPE
client
)
- # Show compact balances
+ # Show compact balances - vertical format with columns
if gateway_data.get("balances_by_network"):
- help_text += r"━━━ Wallet ━━━" + "\n"
-
# Show Solana balances primarily (for LP)
for network, balances in gateway_data["balances_by_network"].items():
if "solana" in network.lower():
- for bal in balances[:5]: # Top 5 tokens
- token = bal["token"]
- units = _format_token_amount(bal["units"])
- value = _format_value(bal["value"])
- help_text += f"💰 `{escape_markdown_v2(token)}`: `{escape_markdown_v2(units)}` {escape_markdown_v2(value)}\n"
- if len(balances) > 5:
- help_text += f" _\\.\\.\\. and {len(balances) - 5} more_\n"
+ # Filter tokens with value >= $0.5
+ tokens = [(bal["token"], _format_value(bal["value"])) for bal in balances if bal["value"] >= 0.5]
+
+ if tokens:
+ # Determine columns based on count: 1-5 = 1col, 6-10 = 2col, 11+ = 3col
+ num_tokens = len(tokens)
+ if num_tokens <= 5:
+ cols = 1
+ elif num_tokens <= 10:
+ cols = 2
+ else:
+ cols = 3
+
+ # Calculate rows needed
+ rows = (num_tokens + cols - 1) // cols
+
+ # Build grid
+ lines = []
+ for row in range(rows):
+ row_parts = []
+ for col in range(cols):
+ idx = row + col * rows
+ if idx < num_tokens:
+ token, value = tokens[idx]
+ row_parts.append(f"{token} {value}")
+ lines.append(" · ".join(row_parts))
+
+ help_text += r"💰 *Wallet*" + "\n"
+ for line in lines:
+ help_text += escape_markdown_v2(line) + "\n"
+
+ if gateway_data["total_value"] > 0:
+ help_text += rf"*Total: {escape_markdown_v2(_format_value(gateway_data['total_value']))}*" + "\n"
+ help_text += "\n"
break
- if gateway_data["total_value"] > 0:
- help_text += f"💵 Total: `{escape_markdown_v2(_format_value(gateway_data['total_value']))}`\n"
-
- help_text += "\n"
-
# Fetch active positions (cached)
lp_data = await cached_call(
context.user_data,
@@ -434,13 +546,15 @@ async def show_liquidity_menu(update: Update, context: ContextTypes.DEFAULT_TYPE
positions = lp_data.get("positions", [])
token_cache = lp_data.get("token_cache", {})
+ token_prices = gateway_data.get("token_prices", {})
context.user_data["token_cache"] = token_cache
+ context.user_data["token_prices"] = token_prices
# Show active positions
if positions:
help_text += rf"━━━ Active Positions \({len(positions)}\) ━━━" + "\n"
for i, pos in enumerate(positions[:5], 1): # Show max 5
- line = _format_compact_position_line(pos, token_cache, index=i)
+ line = _format_compact_position_line(pos, token_cache, index=i, token_prices=token_prices)
help_text += escape_markdown_v2(line) + "\n"
if len(positions) > 5:
@@ -491,9 +605,9 @@ def get_closed_time(pos):
)[:5] # Most recent 5
if closed_positions:
- help_text += r"━━━ Closed Positions \(fees earned\) ━━━" + "\n"
+ help_text += r"━━━ Closed Positions ━━━" + "\n"
for pos in closed_positions:
- line = _format_closed_position_line(pos, token_cache)
+ line = _format_closed_position_line(pos, token_cache, token_prices)
help_text += escape_markdown_v2(line) + "\n"
help_text += "\n"
@@ -512,6 +626,7 @@ def get_closed_time(pos):
# Position action buttons (if positions exist)
positions = context.user_data.get("lp_positions_cache", [])
+ token_cache = context.user_data.get("token_cache", {})
if positions:
# Initialize positions_cache for action handlers
if "positions_cache" not in context.user_data:
@@ -531,19 +646,19 @@ def get_closed_time(pos):
keyboard.append([
InlineKeyboardButton(pair_label, callback_data=f"dex:lp_pos_view:{i}"),
- InlineKeyboardButton("💰", callback_data=f"dex:pos_collect:{i}"),
+ InlineKeyboardButton("🎁", callback_data=f"dex:pos_collect:{i}"),
InlineKeyboardButton("❌", callback_data=f"dex:pos_close:{i}"),
])
# Quick actions row (only if more than shown)
if len(positions) > 5:
keyboard.append([
- InlineKeyboardButton("💰 Collect All", callback_data="dex:lp_collect_all"),
+ InlineKeyboardButton("🎁 Collect All", callback_data="dex:lp_collect_all"),
InlineKeyboardButton("📊 View All", callback_data="dex:manage_positions"),
])
else:
keyboard.append([
- InlineKeyboardButton("💰 Collect All Fees", callback_data="dex:lp_collect_all"),
+ InlineKeyboardButton("🎁 Collect All Fees", callback_data="dex:lp_collect_all"),
])
# Explore pools row - direct access to pool discovery
@@ -581,16 +696,40 @@ def get_closed_time(pos):
disable_web_page_preview=True
)
else:
+ msg = update.callback_query.message
try:
- await update.callback_query.message.edit_text(
- help_text,
- parse_mode="MarkdownV2",
- reply_markup=reply_markup,
- disable_web_page_preview=True
- )
+ # If message is a photo, delete it and send new text message
+ if msg.photo:
+ try:
+ await msg.delete()
+ except Exception:
+ pass
+ await msg.chat.send_message(
+ help_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup,
+ disable_web_page_preview=True
+ )
+ else:
+ await msg.edit_text(
+ help_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup,
+ disable_web_page_preview=True
+ )
except Exception as e:
if "not modified" not in str(e).lower():
logger.warning(f"Failed to edit liquidity menu: {e}")
+ # Fallback: send new message
+ try:
+ await msg.reply_text(
+ help_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup,
+ disable_web_page_preview=True
+ )
+ except Exception:
+ pass
async def handle_lp_refresh(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -761,7 +900,7 @@ def _format_detailed_position_line(pos: dict, token_cache: dict = None) -> str:
except (ValueError, TypeError):
pass
- # Fees earned
+ # Fees earned (collected)
try:
base_fee_f = float(base_fee)
quote_fee_f = float(quote_fee)
@@ -771,9 +910,9 @@ def _format_detailed_position_line(pos: dict, token_cache: dict = None) -> str:
if quote_fee_f > 0.0001:
fee_parts.append(f"{_format_token_amount(quote_fee_f)} {quote_symbol}")
if fee_parts:
- lines.append(f" 🎁 Fees earned: {' + '.join(fee_parts)}")
+ lines.append(f" 💰 Fees earned: {' + '.join(fee_parts)}")
else:
- lines.append(f" 🎁 Fees earned: 0")
+ lines.append(f" 💰 Fees earned: 0")
except (ValueError, TypeError):
pass
@@ -793,6 +932,8 @@ async def handle_lp_history(update: Update, context: ContextTypes.DEFAULT_TYPE,
"""Show position history with filters and pagination"""
from datetime import datetime
+ chat_id = update.effective_chat.id
+
try:
# Get or initialize filters
if reset_filters:
@@ -800,7 +941,7 @@ async def handle_lp_history(update: Update, context: ContextTypes.DEFAULT_TYPE,
else:
filters = get_history_filters(context.user_data, "position")
- client = await get_client()
+ client = await get_client(chat_id)
if not hasattr(client, 'gateway_clmm'):
error_message = format_error_message("Gateway CLMM not available")
diff --git a/handlers/dex/menu.py b/handlers/dex/menu.py
index c0ef452..97c2e6b 100644
--- a/handlers/dex/menu.py
+++ b/handlers/dex/menu.py
@@ -12,7 +12,7 @@
from telegram.ext import ContextTypes
from utils.telegram_formatters import escape_markdown_v2, resolve_token_symbol, KNOWN_TOKENS
-from handlers.config.user_preferences import get_dex_last_swap
+from handlers.config.user_preferences import get_dex_last_swap, get_all_enabled_networks
from servers import get_client
from ._shared import cached_call, invalidate_cache
@@ -101,12 +101,14 @@ def _format_price(price) -> str:
return str(price)
-async def _fetch_balances(client) -> dict:
- """Fetch gateway/DEX balances (blockchain wallets like solana, ethereum)"""
- from collections import defaultdict
+async def _fetch_balances(client, refresh: bool = False) -> dict:
+ """Fetch gateway/DEX balances (blockchain wallets like solana, ethereum)
- # Gateway/blockchain connectors contain these keywords
- GATEWAY_KEYWORDS = ["solana", "ethereum", "polygon", "arbitrum", "base", "avalanche", "optimism"]
+ Args:
+ client: The API client
+ refresh: If True, force refresh from exchanges. If False, use cached state (default)
+ """
+ from collections import defaultdict
data = {
"balances_by_network": defaultdict(list),
@@ -118,7 +120,24 @@ async def _fetch_balances(client) -> dict:
logger.warning("Client has no portfolio attribute")
return data
- result = await client.portfolio.get_state()
+ # Fetch available gateway networks dynamically
+ gateway_networks = set()
+ if hasattr(client, 'gateway'):
+ try:
+ networks_response = await client.gateway.list_networks()
+ networks = networks_response.get('networks', [])
+ # Networks come as "chain-network" format (e.g., "solana-mainnet-beta")
+ for network in networks:
+ if isinstance(network, dict):
+ network_id = network.get('network_id', str(network))
+ else:
+ network_id = str(network)
+ gateway_networks.add(network_id.lower())
+ logger.debug(f"Gateway networks available: {gateway_networks}")
+ except Exception as e:
+ logger.debug(f"Could not fetch gateway networks: {e}")
+
+ result = await client.portfolio.get_state(refresh=refresh)
if not result:
logger.info("Portfolio get_state returned empty result")
return data
@@ -129,8 +148,24 @@ async def _fetch_balances(client) -> dict:
for connector_name, balances in account_data.items():
connector_lower = connector_name.lower()
- # Only include gateway/blockchain connectors (contain solana, ethereum, etc.)
- is_gateway = any(keyword in connector_lower for keyword in GATEWAY_KEYWORDS)
+ # Only include gateway/blockchain connectors
+ # Check if connector matches any known gateway network
+ is_gateway = False
+ if gateway_networks:
+ # Match connector name against known gateway networks
+ # e.g., "solana_mainnet-beta" should match "solana-mainnet-beta"
+ connector_normalized = connector_lower.replace('_', '-')
+ is_gateway = connector_normalized in gateway_networks or any(
+ connector_normalized.startswith(net.split('-')[0])
+ for net in gateway_networks
+ )
+ else:
+ # Fallback: assume connector names containing chain names are gateway connectors
+ # This handles cases where gateway.list_networks() fails
+ is_gateway = any(chain in connector_lower for chain in [
+ 'solana', 'ethereum', 'polygon', 'arbitrum', 'base', 'avalanche', 'optimism'
+ ])
+
if not is_gateway:
logger.debug(f"Skipping non-gateway connector: {connector_name}")
continue
@@ -167,6 +202,48 @@ async def _fetch_balances(client) -> dict:
return data
+def _filter_balances_by_networks(balances_data: dict, enabled_networks: set) -> dict:
+ """Filter balances data to only include enabled networks.
+
+ Args:
+ balances_data: Dict with balances_by_network and total_value
+ enabled_networks: Set of enabled network IDs, or None for no filtering
+
+ Returns:
+ Filtered balances data with recalculated total_value and percentages
+ """
+ if enabled_networks is None or not balances_data:
+ return balances_data
+
+ balances_by_network = balances_data.get("balances_by_network", {})
+ if not balances_by_network:
+ return balances_data
+
+ # Filter networks
+ filtered_networks = {
+ network: balances
+ for network, balances in balances_by_network.items()
+ if network in enabled_networks
+ }
+
+ # Recalculate total value
+ total_value = sum(
+ bal["value"]
+ for balances in filtered_networks.values()
+ for bal in balances
+ )
+
+ # Recalculate percentages
+ for balances in filtered_networks.values():
+ for bal in balances:
+ bal["percentage"] = (bal["value"] / total_value * 100) if total_value > 0 else 0
+
+ return {
+ "balances_by_network": filtered_networks,
+ "total_value": total_value,
+ }
+
+
async def _fetch_lp_positions(client) -> dict:
"""Fetch LP positions only"""
data = {
@@ -362,26 +439,43 @@ async def _load_menu_data_background(
context: ContextTypes.DEFAULT_TYPE,
reply_markup,
last_swap,
- server_name: str = None
+ server_name: str = None,
+ refresh: bool = False,
+ chat_id: int = None
) -> None:
"""Background task to load gateway data and update the menu progressively.
This runs as a background task so users can navigate away without waiting.
Handles cancellation gracefully.
+
+ Args:
+ refresh: If True, force refresh balances from exchanges (bypasses 5-min API cache)
+ chat_id: Chat ID for per-chat server selection
"""
gateway_data = {"balances_by_network": {}, "lp_positions": [], "total_value": 0, "token_cache": {}}
try:
- client = await get_client()
+ client = await get_client(chat_id)
# Step 2: Fetch balances first (usually fast) and update UI immediately
- balances_data = await cached_call(
- context.user_data,
- "gateway_balances",
- _fetch_balances,
- 60,
- client
- )
+ # When refresh=True, bypass local cache and tell API to refresh from exchanges
+ if refresh:
+ # Direct call without caching, with refresh=True for API
+ balances_data = await _fetch_balances(client, refresh=True)
+ else:
+ balances_data = await cached_call(
+ context.user_data,
+ "gateway_balances",
+ _fetch_balances,
+ 60,
+ client
+ )
+
+ # Filter by enabled networks from wallet preferences
+ enabled_networks = get_all_enabled_networks(context.user_data)
+ if enabled_networks:
+ logger.info(f"Filtering DEX balances by enabled networks: {enabled_networks}")
+ balances_data = _filter_balances_by_networks(balances_data, enabled_networks)
gateway_data["balances_by_network"] = balances_data.get("balances_by_network", {})
gateway_data["total_value"] = balances_data.get("total_value", 0)
@@ -443,11 +537,14 @@ async def _load_menu_data_background(
context.user_data.pop(DEX_LOADING_TASK_KEY, None)
-async def show_dex_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+async def show_dex_menu(update: Update, context: ContextTypes.DEFAULT_TYPE, refresh: bool = False) -> None:
"""Display main DEX trading menu with balances and positions
Uses progressive loading: shows menu immediately, then loads data in background.
User can navigate away without waiting for data to load.
+
+ Args:
+ refresh: If True, force refresh balances from exchanges (bypasses API cache)
"""
from servers import server_manager
@@ -500,8 +597,9 @@ async def show_dex_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
last_swap = get_dex_last_swap(context.user_data)
# Spawn background task to load data - user can navigate away without waiting
+ chat_id = update.effective_chat.id
task = asyncio.create_task(
- _load_menu_data_background(message, context, reply_markup, last_swap, server_name)
+ _load_menu_data_background(message, context, reply_markup, last_swap, server_name, refresh=refresh, chat_id=chat_id)
)
context.user_data[DEX_LOADING_TASK_KEY] = task
@@ -520,12 +618,12 @@ async def handle_close(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
async def handle_refresh(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle refresh button - clear cache and reload data"""
+ """Handle refresh button - clear local cache and force refresh from exchanges"""
query = update.callback_query
- await query.answer("Refreshing...")
+ await query.answer("Refreshing from exchanges...")
- # Invalidate all balance, position, and token caches
+ # Invalidate all local balance, position, and token caches
invalidate_cache(context.user_data, "balances", "positions", "tokens")
- # Re-show the menu with fresh data
- await show_dex_menu(update, context)
+ # Re-show the menu with fresh data (refresh=True forces API to fetch from exchanges)
+ await show_dex_menu(update, context, refresh=True)
diff --git a/handlers/dex/pool_data.py b/handlers/dex/pool_data.py
index c4f047b..8693634 100644
--- a/handlers/dex/pool_data.py
+++ b/handlers/dex/pool_data.py
@@ -116,6 +116,7 @@ async def fetch_ohlcv(
pool_address: str,
network: str,
timeframe: str = "1h",
+ currency: str = "usd",
user_data: dict = None
) -> Tuple[Optional[List], Optional[str]]:
"""Fetch OHLCV data for any pool via GeckoTerminal
@@ -124,6 +125,7 @@ async def fetch_ohlcv(
pool_address: Pool contract address
network: Network identifier (will be converted to GeckoTerminal format)
timeframe: OHLCV timeframe ("1m", "5m", "15m", "1h", "4h", "1d")
+ currency: Price currency - "usd" or "token" (quote token)
user_data: Optional user_data dict for caching
Returns:
@@ -136,13 +138,13 @@ async def fetch_ohlcv(
# Check cache
if user_data is not None:
- cache_key = f"ohlcv_{gecko_network}_{pool_address}_{timeframe}"
+ cache_key = f"ohlcv_{gecko_network}_{pool_address}_{timeframe}_{currency}"
cached = get_cached(user_data, cache_key, ttl=OHLCV_CACHE_TTL)
if cached is not None:
return cached, None
client = GeckoTerminalAsyncClient()
- result = await client.get_ohlcv(gecko_network, pool_address, timeframe)
+ result = await client.get_ohlcv(gecko_network, pool_address, timeframe, currency=currency)
# Parse response - handle different formats
ohlcv_list = None
@@ -186,7 +188,8 @@ async def fetch_liquidity_bins(
pool_address: str,
connector: str = "meteora",
network: str = "solana-mainnet-beta",
- user_data: dict = None
+ user_data: dict = None,
+ chat_id: int = None
) -> Tuple[Optional[List], Optional[Dict], Optional[str]]:
"""Fetch liquidity bin data for CLMM pools via gateway
@@ -195,6 +198,7 @@ async def fetch_liquidity_bins(
connector: DEX connector (meteora, raydium, orca)
network: Network identifier
user_data: Optional user_data dict for caching
+ chat_id: Chat ID for per-chat server selection
Returns:
Tuple of (bins_list, pool_info, error_message)
@@ -213,7 +217,7 @@ async def fetch_liquidity_bins(
if cached is not None:
return cached.get('bins'), cached, None
- client = await get_client()
+ client = await get_client(chat_id)
if not client:
return None, None, "Gateway client not available"
diff --git a/handlers/dex/pools.py b/handlers/dex/pools.py
index 7892191..5778c05 100644
--- a/handlers/dex/pools.py
+++ b/handlers/dex/pools.py
@@ -27,12 +27,13 @@
# TOKEN CACHE HELPERS
# ============================================
-async def get_token_cache_from_gateway(network: str = "solana-mainnet-beta") -> dict:
+async def get_token_cache_from_gateway(network: str = "solana-mainnet-beta", chat_id: int = None) -> dict:
"""
Fetch tokens from Gateway and build address->symbol cache.
Args:
network: Network ID (default: solana-mainnet-beta)
+ chat_id: Chat ID for per-chat server selection
Returns:
Dict mapping token addresses to symbols
@@ -40,7 +41,7 @@ async def get_token_cache_from_gateway(network: str = "solana-mainnet-beta") ->
token_cache = dict(KNOWN_TOKENS) # Start with known tokens
try:
- client = await get_client()
+ client = await get_client(chat_id)
# Try to get tokens from Gateway
if hasattr(client, 'gateway'):
@@ -211,7 +212,8 @@ async def process_pool_info(
if connector not in supported_connectors:
raise ValueError(f"Unsupported connector '{connector}'. Use: {', '.join(supported_connectors)}")
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
if not hasattr(client, 'gateway_clmm'):
raise ValueError("Gateway CLMM not available")
@@ -278,46 +280,50 @@ async def process_pool_info(
# ============================================
def _build_balance_table_compact(gateway_data: dict) -> str:
- """Build a compact balance table for display in pool list prompt"""
+ """Build a compact balance table for display in pool list prompt (Solana tokens only)"""
if not gateway_data or not gateway_data.get("balances_by_network"):
- return ""
-
- lines = [r"💰 *Your Tokens:*" + "\n"]
+ return r"_💡 Use /lp to load your wallet tokens_" + "\n\n"
+ # Find Solana balances specifically (Meteora is Solana-based)
+ solana_balances = None
for network, balances in gateway_data["balances_by_network"].items():
- if not balances:
- continue
-
- # Create compact table for this network
- lines.append(f"```")
- lines.append(f"{'Token':<8} {'Amount':<12} {'Value':>8}")
- lines.append(f"{'─'*8} {'─'*12} {'─'*8}")
-
- # Show top 5 tokens per network
- for bal in balances[:5]:
- token = bal["token"][:7]
- units = bal["units"]
- value = bal["value"]
-
- # Format units compactly
- if units >= 1000:
- units_str = f"{units/1000:.1f}K"
- elif units >= 1:
- units_str = f"{units:.2f}"
- else:
- units_str = f"{units:.4f}"
- units_str = units_str[:11]
+ if "solana" in network.lower() and balances:
+ solana_balances = balances
+ break
+
+ if not solana_balances:
+ return r"_💡 No Solana tokens found_" + "\n\n"
+
+ lines = [r"💰 *Your Solana Tokens:*" + "\n"]
+ lines.append(f"```")
+ lines.append(f"{'Token':<8} {'Amount':<12} {'Value':>8}")
+ lines.append(f"{'─'*8} {'─'*12} {'─'*8}")
+
+ # Show top 8 tokens
+ for bal in solana_balances[:8]:
+ token = bal["token"][:7]
+ units = bal["units"]
+ value = bal["value"]
+
+ # Format units compactly
+ if units >= 1000:
+ units_str = f"{units/1000:.1f}K"
+ elif units >= 1:
+ units_str = f"{units:.2f}"
+ else:
+ units_str = f"{units:.4f}"
+ units_str = units_str[:11]
- # Format value
- if value >= 1000:
- value_str = f"${value/1000:.1f}K"
- else:
- value_str = f"${value:.0f}"
- value_str = value_str[:8]
+ # Format value
+ if value >= 1000:
+ value_str = f"${value/1000:.1f}K"
+ else:
+ value_str = f"${value:.0f}"
+ value_str = value_str[:8]
- lines.append(f"{token:<8} {units_str:<12} {value_str:>8}")
+ lines.append(f"{token:<8} {units_str:<12} {value_str:>8}")
- lines.append(f"```\n")
+ lines.append(f"```\n")
return "\n".join(lines)
@@ -393,7 +399,7 @@ def _format_percent(value, decimals: int = 2) -> str:
def _format_pool_table(pools: list) -> str:
"""Format pools as a compact table optimized for mobile
- Shows: #, Pair, APR%, Bin, Fee, TVL, V/T (vol/tvl ratio)
+ Shows: #, Pair, APR%, Bin, Fee, TVL
Args:
pools: List of pool data dictionaries
@@ -406,60 +412,52 @@ def _format_pool_table(pools: list) -> str:
lines = []
- # Header - balanced for mobile (~42 chars)
+ # Header - balanced for mobile (~40 chars)
lines.append("```")
- lines.append(f"{'#':>2} {'Pair':<10} {'APR%':>5} {'Bin':>3} {'Fee':>5} {'TVL':>5} {'V/T':>5}")
- lines.append("─" * 42)
+ lines.append(f"{'#':>2} {'Pair':<12} {'APR%':>7} {'Bin':>3} {'Fee':>4} {'TVL':>5}")
+ lines.append("─" * 40)
for i, pool in enumerate(pools):
idx = str(i + 1)
- # Truncate pair to 10 chars (fits AVICI-USDC)
- pair = pool.get('trading_pair', 'N/A')[:10]
+ # Truncate pair to 12 chars
+ pair = pool.get('trading_pair', 'N/A')[:12]
- # Get TVL and Vol values for ratio calculation
+ # Get TVL value
tvl_val = 0
- vol_val = 0
try:
tvl_val = float(pool.get('liquidity', 0) or 0)
except (ValueError, TypeError):
pass
- try:
- vol_val = float(pool.get('volume_24h', 0) or 0)
- except (ValueError, TypeError):
- pass
# Compact TVL
tvl = _format_compact(tvl_val)
- # V/TVL ratio - shows how active the pool is
- if tvl_val > 0 and vol_val > 0:
- ratio = vol_val / tvl_val
- if ratio >= 10:
- ratio_str = f"{int(ratio)}x"
- elif ratio >= 1:
- ratio_str = f"{ratio:.1f}x"
- else:
- ratio_str = f".{int(ratio*100):02d}x"
- else:
- ratio_str = "—"
-
- # Base fee percentage - 2 decimal places
+ # Base fee percentage - compact
base_fee = pool.get('base_fee_percentage')
if base_fee:
try:
fee_val = float(base_fee)
- fee_str = f"{fee_val:.2f}"
+ fee_str = f"{fee_val:.1f}" if fee_val < 10 else f"{int(fee_val)}"
except (ValueError, TypeError):
fee_str = "—"
else:
fee_str = "—"
- # APR percentage - always 2 decimals
+ # APR percentage - compact format for large values
apr = pool.get('apr')
if apr:
try:
apr_val = float(apr)
- apr_str = f"{apr_val:.2f}"
+ if apr_val >= 1000000:
+ apr_str = f"{apr_val/1000000:.0f}M"
+ elif apr_val >= 10000:
+ apr_str = f"{apr_val/1000:.0f}K"
+ elif apr_val >= 1000:
+ apr_str = f"{apr_val/1000:.1f}K"
+ elif apr_val >= 100:
+ apr_str = f"{apr_val:.0f}"
+ else:
+ apr_str = f"{apr_val:.1f}"
except (ValueError, TypeError):
apr_str = "—"
else:
@@ -468,7 +466,7 @@ def _format_pool_table(pools: list) -> str:
# Bin step
bin_step = pool.get('bin_step', '—')
- lines.append(f"{idx:>2} {pair:<10} {apr_str:>5} {bin_step:>3} {fee_str:>5} {tvl:>5} {ratio_str:>5}")
+ lines.append(f"{idx:>2} {pair:<12} {apr_str:>7} {bin_step:>3} {fee_str:>4} {tvl:>5}")
lines.append("```")
@@ -550,6 +548,33 @@ async def process_pool_list(
if 0 <= pool_index < len(cached_pools):
pool = cached_pools[pool_index]
+
+ # Show immediate feedback
+ pair = pool.get('trading_pair', pool.get('name', 'Pool'))
+
+ # Delete user's message
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ # Show loading state
+ message_id = context.user_data.get("pool_list_message_id")
+ chat_id = context.user_data.get("pool_list_chat_id")
+ if message_id and chat_id:
+ try:
+ loading_text = rf"⏳ *Loading pool data\.\.\.*" + "\n\n"
+ loading_text += escape_markdown_v2(f"🏊 {pair}") + "\n"
+ loading_text += r"_Fetching liquidity bins and pool info\.\.\._"
+ await update.get_bot().edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text=loading_text,
+ parse_mode="MarkdownV2"
+ )
+ except Exception:
+ pass
+
await _show_pool_detail(update, context, pool)
return
else:
@@ -596,9 +621,14 @@ async def process_pool_list(
sent_msg = await update.message.reply_text(loading_msg, parse_mode="MarkdownV2")
context.user_data["pool_list_message_id"] = sent_msg.message_id
context.user_data["pool_list_chat_id"] = sent_msg.chat_id
+ chat_id = sent_msg.chat_id # Ensure chat_id is set
loading_sent = "new"
- client = await get_client()
+ # Ensure chat_id is set for get_client
+ if not chat_id:
+ chat_id = update.effective_chat.id
+
+ client = await get_client(chat_id)
if not hasattr(client, 'gateway_clmm'):
raise ValueError("Gateway CLMM not available")
@@ -618,8 +648,8 @@ async def process_pool_list(
keyboard = [[InlineKeyboardButton("« LP Menu", callback_data="dex:liquidity")]]
reply_markup = InlineKeyboardMarkup(keyboard)
else:
- # Sort by APR% descending, filter out zero TVL
- active_pools = [p for p in pools if float(p.get('liquidity', 0)) > 0]
+ # Sort by APR% descending, filter out low TVL pools (< $100)
+ active_pools = [p for p in pools if float(p.get('liquidity', 0) or 0) >= 100]
active_pools.sort(key=lambda x: float(x.get('apr', 0) or 0), reverse=True)
# If no active pools, show all
@@ -738,15 +768,21 @@ async def handle_plot_liquidity(
)
try:
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
+
+ # Fetch all pool infos in parallel with individual timeouts
+ POOL_FETCH_TIMEOUT = 10 # seconds per pool
- # Fetch all pool infos in parallel
async def fetch_pool_with_info(pool):
"""Fetch pool info and return combined data"""
pool_address = pool.get('pool_address', pool.get('address', ''))
connector = pool.get('connector', 'meteora')
try:
- pool_info = await _fetch_pool_info(client, pool_address, connector)
+ pool_info = await asyncio.wait_for(
+ _fetch_pool_info(client, pool_address, connector),
+ timeout=POOL_FETCH_TIMEOUT
+ )
bins = pool_info.get('bins', [])
bin_step = pool.get('bin_step') or pool_info.get('bin_step')
@@ -762,6 +798,9 @@ async def fetch_pool_with_info(pool):
'bins': bins,
'bin_step': bin_step
}
+ except asyncio.TimeoutError:
+ logger.warning(f"Timeout fetching pool {pool_address[:12]}... after {POOL_FETCH_TIMEOUT}s")
+ return None
except Exception as e:
logger.warning(f"Failed to fetch pool {pool_address}: {e}")
return None
@@ -770,11 +809,14 @@ async def fetch_pool_with_info(pool):
tasks = [fetch_pool_with_info(pool) for pool in selected_pools]
results = await asyncio.gather(*tasks, return_exceptions=True)
- # Filter successful results
+ # Filter successful results and count failures
pools_data = [r for r in results if r is not None and not isinstance(r, Exception)]
+ failed_count = len(selected_pools) - len(pools_data)
if not pools_data:
- await query.message.edit_text("❌ Failed to fetch pool data.")
+ await query.message.edit_text(
+ f"❌ Failed to fetch pool data. All {len(selected_pools)} pools failed or timed out."
+ )
return
# Log summary of what we got
@@ -831,7 +873,7 @@ async def fetch_pool_with_info(pool):
lines = [
f"📊 Aggregated Liquidity: {pair_name}",
"",
- f"📈 Pools included: {len(pools_data)}",
+ f"📈 Pools included: {len(pools_data)}" + (f" ({failed_count} failed)" if failed_count else ""),
f"💰 Total TVL: ${_format_number(total_tvl_selected)}",
f"📊 Percentile: Top {percentile}%",
f"🎯 Min bin step (resolution): {min_bin_step}",
@@ -933,17 +975,18 @@ async def _show_pool_detail(
network = 'solana-mainnet-beta'
# Fetch additional pool info with bins (cached with 60s TTL)
+ chat_id = update.effective_chat.id
cache_key = f"pool_info_{connector}_{pool_address}"
pool_info = get_cached(context.user_data, cache_key, ttl=DEFAULT_CACHE_TTL)
if pool_info is None:
- client = await get_client()
+ client = await get_client(chat_id)
pool_info = await _fetch_pool_info(client, pool_address, connector)
set_cached(context.user_data, cache_key, pool_info)
# Get or fetch token cache for symbol resolution
token_cache = context.user_data.get("token_cache")
if not token_cache:
- token_cache = await get_token_cache_from_gateway()
+ token_cache = await get_token_cache_from_gateway(chat_id=chat_id)
context.user_data["token_cache"] = token_cache
# Try to get trading pair name from multiple sources
@@ -1006,14 +1049,35 @@ async def _show_pool_detail(
# Calculate max range and auto-fill if not set
if current_price and bin_step:
try:
+ current_price_float = float(current_price)
+ bin_step_int = int(bin_step)
+
+ # Get default percentages for 20 bins each side
+ default_lower_pct, default_upper_pct = _get_default_range_percent(bin_step_int, 20)
+
suggested_lower, suggested_upper = _calculate_max_range(
- float(current_price),
- int(bin_step)
+ current_price_float,
+ bin_step_int
)
+ # Auto-fill if empty - store both price and percentage
if suggested_lower and not params.get('lower_price'):
- params['lower_price'] = f"{suggested_lower:.6f}"
+ params['lower_price'] = f"{suggested_lower:.10f}".rstrip('0').rstrip('.')
+ params['lower_pct'] = default_lower_pct
if suggested_upper and not params.get('upper_price'):
- params['upper_price'] = f"{suggested_upper:.6f}"
+ params['upper_price'] = f"{suggested_upper:.10f}".rstrip('0').rstrip('.')
+ params['upper_pct'] = default_upper_pct
+
+ # Calculate percentages for existing prices if not set
+ if params.get('lower_price') and not params.get('lower_pct'):
+ try:
+ params['lower_pct'] = _price_to_percent(current_price_float, float(params['lower_price']))
+ except (ValueError, TypeError):
+ pass
+ if params.get('upper_price') and not params.get('upper_pct'):
+ try:
+ params['upper_pct'] = _price_to_percent(current_price_float, float(params['upper_price']))
+ except (ValueError, TypeError):
+ pass
except (ValueError, TypeError) as e:
logger.warning(f"Failed to calculate range: {e}")
@@ -1088,7 +1152,7 @@ async def _show_pool_detail(
balance_cache_key = f"token_balances_{network}_{base_symbol}_{quote_symbol}"
balances = get_cached(context.user_data, balance_cache_key, ttl=DEFAULT_CACHE_TTL)
if balances is None:
- client = await get_client()
+ client = await get_client(chat_id)
balances = await _fetch_token_balances(client, network, base_symbol, quote_symbol)
set_cached(context.user_data, balance_cache_key, balances)
@@ -1097,20 +1161,97 @@ async def _show_pool_detail(
quote_bal_str = _format_number(balances["quote_balance"])
lines.append(f"💰 {base_symbol}: {base_bal_str}")
lines.append(f"💵 {quote_symbol}: {quote_bal_str}")
- message += escape_markdown_v2("\n".join(lines))
+ message += escape_markdown_v2("\n".join(lines)) + "\n"
context.user_data["token_balances"] = balances
except Exception as e:
logger.warning(f"Could not fetch token balances: {e}")
+ balances = context.user_data.get("token_balances", {"base_balance": 0, "quote_balance": 0})
# Store pool for add position and add to gateway
context.user_data["selected_pool"] = pool
context.user_data["selected_pool_info"] = pool_info
context.user_data["dex_state"] = "add_position"
- # Build add position display values
- lower_display = params.get('lower_price', '—')[:8] if params.get('lower_price') else '—'
- upper_display = params.get('upper_price', '—')[:8] if params.get('upper_price') else '—'
+ # ========== POSITION PREVIEW ==========
+ # Show preview of the position to be created
+ message += "\n" + escape_markdown_v2("━━━ Position Preview ━━━") + "\n"
+
+ # Get amounts and calculate actual values
+ base_amount_str = params.get('amount_base', '10%')
+ quote_amount_str = params.get('amount_quote', '10%')
+
+ try:
+ if base_amount_str.endswith('%'):
+ base_pct_val = float(base_amount_str[:-1])
+ base_amount = balances.get("base_balance", 0) * base_pct_val / 100
+ else:
+ base_amount = float(base_amount_str) if base_amount_str else 0
+ except (ValueError, TypeError):
+ base_amount = 0
+
+ try:
+ if quote_amount_str.endswith('%'):
+ quote_pct_val = float(quote_amount_str[:-1])
+ quote_amount = balances.get("quote_balance", 0) * quote_pct_val / 100
+ else:
+ quote_amount = float(quote_amount_str) if quote_amount_str else 0
+ except (ValueError, TypeError):
+ quote_amount = 0
+
+ # Show price range with percentages
+ lower_pct_preview = params.get('lower_pct')
+ upper_pct_preview = params.get('upper_pct')
+ lower_price_str = params.get('lower_price', '')
+ upper_price_str = params.get('upper_price', '')
+
+ if lower_price_str and upper_price_str:
+ try:
+ lower_p = float(lower_price_str)
+ upper_p = float(upper_price_str)
+
+ # Format prices nicely
+ if lower_p >= 1:
+ l_str = f"{lower_p:.4f}"
+ elif lower_p >= 0.0001:
+ l_str = f"{lower_p:.6f}"
+ else:
+ l_str = f"{lower_p:.8f}"
+
+ if upper_p >= 1:
+ u_str = f"{upper_p:.4f}"
+ elif upper_p >= 0.0001:
+ u_str = f"{upper_p:.6f}"
+ else:
+ u_str = f"{upper_p:.8f}"
+
+ # Show range with percentages
+ l_pct_str = f"({lower_pct_preview:+.1f}%)" if lower_pct_preview is not None else ""
+ u_pct_str = f"({upper_pct_preview:+.1f}%)" if upper_pct_preview is not None else ""
+ message += f"📉 *L:* `{escape_markdown_v2(l_str)}` _{escape_markdown_v2(l_pct_str)}_\n"
+ message += f"📈 *U:* `{escape_markdown_v2(u_str)}` _{escape_markdown_v2(u_pct_str)}_\n"
+
+ # Validate bin range and show
+ if current_price and bin_step:
+ is_valid, total_bins, error_msg = _validate_bin_range(
+ lower_p, upper_p, float(current_price), int(bin_step), max_bins=68
+ )
+ if not is_valid:
+ message += f"⚠️ _{escape_markdown_v2(error_msg)}_\n"
+ elif total_bins > 0:
+ message += f"📊 *Bins:* `{total_bins}` _\\(max 68\\)_\n"
+ except (ValueError, TypeError):
+ pass
+
+ # Show calculated amounts
+ message += f"💰 *{escape_markdown_v2(base_symbol)}:* `{escape_markdown_v2(_format_number(base_amount))}` _\\({escape_markdown_v2(base_amount_str)}\\)_\n"
+ message += f"💵 *{escape_markdown_v2(quote_symbol)}:* `{escape_markdown_v2(_format_number(quote_amount))}` _\\({escape_markdown_v2(quote_amount_str)}\\)_\n"
+
+ # Build add position display values - show percentages in buttons
+ lower_pct = params.get('lower_pct')
+ upper_pct = params.get('upper_pct')
+ lower_display = f"{lower_pct:.1f}%" if lower_pct is not None else (params.get('lower_price', '—')[:8] if params.get('lower_price') else '—')
+ upper_display = f"+{upper_pct:.1f}%" if upper_pct is not None and upper_pct >= 0 else (f"{upper_pct:.1f}%" if upper_pct is not None else (params.get('upper_price', '—')[:8] if params.get('upper_price') else '—'))
base_display = params.get('amount_base') or '10%'
quote_display = params.get('amount_quote') or '10%'
strategy_display = params.get('strategy_type', '0')
@@ -1225,6 +1366,7 @@ async def _show_pool_detail(
context.user_data["pool_detail_chat_id"] = chat.id
context.user_data["add_position_menu_msg_id"] = sent_msg.message_id
context.user_data["add_position_menu_chat_id"] = chat.id
+ context.user_data["add_position_menu_is_photo"] = True
except Exception as e:
logger.warning(f"Failed to send chart photo: {e}")
sent_msg = await chat.send_message(
@@ -1236,6 +1378,7 @@ async def _show_pool_detail(
context.user_data["pool_detail_chat_id"] = sent_msg.chat.id
context.user_data["add_position_menu_msg_id"] = sent_msg.message_id
context.user_data["add_position_menu_chat_id"] = sent_msg.chat.id
+ context.user_data["add_position_menu_is_photo"] = False
else:
sent_msg = await chat.send_message(
text=message,
@@ -1246,17 +1389,33 @@ async def _show_pool_detail(
context.user_data["pool_detail_chat_id"] = sent_msg.chat.id
context.user_data["add_position_menu_msg_id"] = sent_msg.message_id
context.user_data["add_position_menu_chat_id"] = sent_msg.chat.id
+ context.user_data["add_position_menu_is_photo"] = False
async def handle_pool_select(update: Update, context: ContextTypes.DEFAULT_TYPE, pool_index: int) -> None:
"""Handle pool selection from numbered button"""
+ query = update.callback_query
cached_pools = context.user_data.get("pool_list_cache", [])
if 0 <= pool_index < len(cached_pools):
pool = cached_pools[pool_index]
+
+ # Show immediate feedback
+ pair = pool.get('trading_pair', pool.get('name', 'Pool'))
+ await query.answer(f"Loading {pair}...")
+
+ # Show loading state in message
+ try:
+ loading_text = rf"⏳ *Loading pool data\.\.\.*" + "\n\n"
+ loading_text += escape_markdown_v2(f"🏊 {pair}") + "\n"
+ loading_text += r"_Fetching liquidity bins and pool info\.\.\._"
+ await query.message.edit_text(loading_text, parse_mode="MarkdownV2")
+ except Exception:
+ pass
+
await _show_pool_detail(update, context, pool, from_callback=True)
else:
- await update.callback_query.answer("Pool not found. Please search again.")
+ await query.answer("Pool not found. Please search again.")
async def handle_pool_detail_refresh(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -1320,7 +1479,7 @@ async def handle_add_to_gateway(update: Update, context: ContextTypes.DEFAULT_TY
await query.message.edit_caption(
caption=escape_markdown_v2("🔄 Adding tokens to Gateway..."),
parse_mode="MarkdownV2"
- ) if query.message.photo else await query.message.edit_text(
+ ) if getattr(query.message, 'photo', None) else await query.message.edit_text(
escape_markdown_v2("🔄 Adding tokens to Gateway..."),
parse_mode="MarkdownV2"
)
@@ -1396,7 +1555,7 @@ async def add_token_to_gateway(token_address: str) -> bool:
await query.message.edit_caption(
caption=escape_markdown_v2(success_msg),
parse_mode="MarkdownV2"
- ) if query.message.photo else await query.message.edit_text(
+ ) if getattr(query.message, 'photo', None) else await query.message.edit_text(
escape_markdown_v2(success_msg),
parse_mode="MarkdownV2"
)
@@ -1456,13 +1615,14 @@ async def handle_pool_list_back(update: Update, context: ContextTypes.DEFAULT_TY
# POOL OHLCV CHARTS (via GeckoTerminal)
# ============================================
-async def handle_pool_ohlcv(update: Update, context: ContextTypes.DEFAULT_TYPE, timeframe: str) -> None:
+async def handle_pool_ohlcv(update: Update, context: ContextTypes.DEFAULT_TYPE, timeframe: str, currency: str = "usd") -> None:
"""Show OHLCV chart for the selected pool using GeckoTerminal
Args:
update: Telegram update
context: Bot context
timeframe: OHLCV timeframe (1m, 5m, 15m, 1h, 4h, 1d)
+ currency: Price currency - "usd" or "token" (quote token)
"""
from io import BytesIO
from telegram import InputMediaPhoto
@@ -1480,6 +1640,14 @@ async def handle_pool_ohlcv(update: Update, context: ContextTypes.DEFAULT_TYPE,
pool_address = pool.get('pool_address', pool.get('address', ''))
pair = pool.get('trading_pair') or pool.get('name', 'Pool')
+ # Get quote token symbol for display
+ quote_token = pool.get('quote_token', pool.get('token_b', ''))
+ quote_symbol = pool.get('quote_symbol', '')
+ if not quote_symbol and quote_token:
+ quote_symbol = resolve_token_symbol(quote_token, context.user_data.get("token_cache", {}))
+ if not quote_symbol:
+ quote_symbol = "Quote"
+
# Get network - default to Solana for CLMM pools
network = pool.get('network', 'solana')
@@ -1505,6 +1673,7 @@ async def handle_pool_ohlcv(update: Update, context: ContextTypes.DEFAULT_TYPE,
pool_address=pool_address,
network=network,
timeframe=timeframe,
+ currency=currency,
user_data=context.user_data
)
@@ -1537,22 +1706,30 @@ async def handle_pool_ohlcv(update: Update, context: ContextTypes.DEFAULT_TYPE,
reply_markup=InlineKeyboardMarkup(keyboard))
return
+ # Toggle currency for button
+ other_currency = "token" if currency == "usd" else "usd"
+ currency_label = "USD" if currency == "usd" else quote_symbol
+ toggle_label = quote_symbol if currency == "usd" else "USD"
+
# Build timeframe buttons
keyboard = [
[
- InlineKeyboardButton("1h" if timeframe != "1m" else "• 1h •", callback_data="dex:pool_ohlcv:1m"),
- InlineKeyboardButton("1d" if timeframe != "1h" else "• 1d •", callback_data="dex:pool_ohlcv:1h"),
- InlineKeyboardButton("7d" if timeframe != "1d" else "• 7d •", callback_data="dex:pool_ohlcv:1d"),
+ InlineKeyboardButton("1h" if timeframe != "1m" else "• 1h •", callback_data=f"dex:pool_ohlcv:1m:{currency}"),
+ InlineKeyboardButton("1d" if timeframe != "1h" else "• 1d •", callback_data=f"dex:pool_ohlcv:1h:{currency}"),
+ InlineKeyboardButton("7d" if timeframe != "1d" else "• 7d •", callback_data=f"dex:pool_ohlcv:1d:{currency}"),
+ ],
+ [
+ InlineKeyboardButton(f"💱 {toggle_label}", callback_data=f"dex:pool_ohlcv:{timeframe}:{other_currency}"),
+ InlineKeyboardButton("📊 + Liquidity", callback_data=f"dex:pool_combined:{timeframe}:{currency}"),
],
[
- InlineKeyboardButton("📊 + Liquidity", callback_data=f"dex:pool_combined:{timeframe}"),
InlineKeyboardButton("« Back to Pool", callback_data="dex:pool_detail_refresh"),
]
]
# Build caption
caption = f"📈 *{escape_markdown_v2(pair)}* \\- {escape_markdown_v2(_format_timeframe_label(timeframe))}\n"
- caption += f"_Price in USD \\({escape_markdown_v2(f'{len(ohlcv_data)} candles')}\\)_"
+ caption += f"_Price in {escape_markdown_v2(currency_label)} \\({escape_markdown_v2(f'{len(ohlcv_data)} candles')}\\)_"
reply_markup = InlineKeyboardMarkup(keyboard)
@@ -1587,13 +1764,14 @@ async def handle_pool_ohlcv(update: Update, context: ContextTypes.DEFAULT_TYPE,
pass
-async def handle_pool_combined_chart(update: Update, context: ContextTypes.DEFAULT_TYPE, timeframe: str) -> None:
+async def handle_pool_combined_chart(update: Update, context: ContextTypes.DEFAULT_TYPE, timeframe: str, currency: str = "usd") -> None:
"""Show combined OHLCV + Liquidity chart for the selected pool
Args:
update: Telegram update
context: Bot context
timeframe: OHLCV timeframe
+ currency: Price currency - "usd" or "token" (quote token)
"""
from io import BytesIO
@@ -1613,9 +1791,17 @@ async def handle_pool_combined_chart(update: Update, context: ContextTypes.DEFAU
network = pool.get('network', 'solana')
current_price = pool_info.get('price') or pool.get('current_price')
+ # Get quote token symbol for display
+ quote_token = pool.get('quote_token', pool.get('token_b', ''))
+ quote_symbol = pool.get('quote_symbol', '')
+ if not quote_symbol and quote_token:
+ quote_symbol = resolve_token_symbol(quote_token, context.user_data.get("token_cache", {}))
+ if not quote_symbol:
+ quote_symbol = "Quote"
+
# Show loading - keep the message reference for editing
loading_msg = query.message
- if query.message.photo:
+ if getattr(query.message, 'photo', None):
# Edit photo caption to show loading
try:
await query.message.edit_caption(
@@ -1636,16 +1822,19 @@ async def handle_pool_combined_chart(update: Update, context: ContextTypes.DEFAU
pool_address=pool_address,
network=network,
timeframe=timeframe,
+ currency=currency,
user_data=context.user_data
)
# Get bins from cached pool_info or fetch
bins = pool_info.get('bins', [])
if not bins:
+ chat_id = update.effective_chat.id
bins, _, _ = await fetch_liquidity_bins(
pool_address=pool_address,
connector=connector,
- user_data=context.user_data
+ user_data=context.user_data,
+ chat_id=chat_id
)
if not ohlcv_data and not bins:
@@ -1665,22 +1854,30 @@ async def handle_pool_combined_chart(update: Update, context: ContextTypes.DEFAU
await loading_msg.edit_text("❌ Failed to generate combined chart")
return
+ # Toggle currency for button
+ other_currency = "token" if currency == "usd" else "usd"
+ currency_label = "USD" if currency == "usd" else quote_symbol
+ toggle_label = quote_symbol if currency == "usd" else "USD"
+
# Build keyboard
keyboard = [
[
- InlineKeyboardButton("1h" if timeframe != "1m" else "• 1h •", callback_data="dex:pool_combined:1m"),
- InlineKeyboardButton("1d" if timeframe != "1h" else "• 1d •", callback_data="dex:pool_combined:1h"),
- InlineKeyboardButton("7d" if timeframe != "1d" else "• 7d •", callback_data="dex:pool_combined:1d"),
+ InlineKeyboardButton("1h" if timeframe != "1m" else "• 1h •", callback_data=f"dex:pool_combined:1m:{currency}"),
+ InlineKeyboardButton("1d" if timeframe != "1h" else "• 1d •", callback_data=f"dex:pool_combined:1h:{currency}"),
+ InlineKeyboardButton("7d" if timeframe != "1d" else "• 7d •", callback_data=f"dex:pool_combined:1d:{currency}"),
+ ],
+ [
+ InlineKeyboardButton(f"💱 {toggle_label}", callback_data=f"dex:pool_combined:{timeframe}:{other_currency}"),
+ InlineKeyboardButton("📈 OHLCV Only", callback_data=f"dex:pool_ohlcv:{timeframe}:{currency}"),
],
[
- InlineKeyboardButton("📈 OHLCV Only", callback_data=f"dex:pool_ohlcv:{timeframe}"),
InlineKeyboardButton("« Back to Pool", callback_data="dex:pool_detail_refresh"),
]
]
# Build caption
caption = f"📊 *{escape_markdown_v2(pair)}* \\- Combined View\n"
- caption += f"_OHLCV in USD \\({escape_markdown_v2(_format_timeframe_label(timeframe))}\\) \\+ Liquidity_"
+ caption += f"_OHLCV in {escape_markdown_v2(currency_label)} \\({escape_markdown_v2(_format_timeframe_label(timeframe))}\\) \\+ Liquidity_"
# Edit or send photo
from telegram import InputMediaPhoto
@@ -1708,12 +1905,12 @@ async def handle_pool_combined_chart(update: Update, context: ContextTypes.DEFAU
def _format_timeframe_label(timeframe: str) -> str:
"""Convert API timeframe to display label"""
labels = {
- "1m": "1 Hour (1m candles)",
- "5m": "5 Hours (5m candles)",
- "15m": "15 Hours (15m candles)",
- "1h": "1 Day (1h candles)",
- "4h": "4 Days (4h candles)",
- "1d": "7 Days (1d candles)",
+ "1m": "1m candles",
+ "5m": "5m candles",
+ "15m": "15m candles",
+ "1h": "1h candles",
+ "4h": "4h candles",
+ "1d": "1d candles",
}
return labels.get(timeframe, timeframe)
@@ -1722,7 +1919,7 @@ def _format_timeframe_label(timeframe: str) -> str:
# MANAGE POSITIONS (unified view)
# ============================================
-def _format_position_detail(pos: dict, token_cache: dict = None, detailed: bool = False) -> str:
+def _format_position_detail(pos: dict, token_cache: dict = None, detailed: bool = False, token_prices: dict = None) -> str:
"""
Format a single position for display.
@@ -1730,11 +1927,13 @@ def _format_position_detail(pos: dict, token_cache: dict = None, detailed: bool
pos: Position data dictionary
token_cache: Optional token address->symbol mapping
detailed: If True, show full details; if False, show compact summary
+ token_prices: Optional token symbol -> USD price mapping
Returns:
Formatted position string (not escaped)
"""
token_cache = token_cache or {}
+ token_prices = token_prices or {}
# Resolve token addresses to symbols
base_token = pos.get('base_token', pos.get('token_a', ''))
@@ -1775,17 +1974,22 @@ def _format_position_detail(pos: dict, token_cache: dict = None, detailed: bool
if detailed:
# Full detailed view
if pool_address:
- lines.append(f"📍 Pool: {pool_address[:16]}...")
+ lines.append(f"📍 Pool: `{pool_address}`")
- # Range with status indicator
+ # Range with status indicator - show appropriate decimals based on price magnitude
if lower and upper:
try:
lower_f = float(lower)
upper_f = float(upper)
if lower_f >= 1:
- range_str = f"{lower_f:.2f} - {upper_f:.2f}"
+ decimals = 2
+ elif lower_f >= 0.01:
+ decimals = 4
+ elif lower_f >= 0.0001:
+ decimals = 6
else:
- range_str = f"{lower_f:.4f} - {upper_f:.4f}"
+ decimals = 8
+ range_str = f"{lower_f:.{decimals}f} - {upper_f:.{decimals}f}"
lines.append(f"{range_emoji} Range: [{range_str}]")
except (ValueError, TypeError):
lines.append(f"{range_emoji} Range: [{lower} - {upper}]")
@@ -1803,6 +2007,31 @@ def _format_position_detail(pos: dict, token_cache: dict = None, detailed: bool
lines.append("") # Separator
+ # Get token prices for USD conversion (try exact match, then variants)
+ def get_price(symbol, default=0):
+ if symbol in token_prices:
+ return token_prices[symbol]
+ # Try case-insensitive match
+ symbol_lower = symbol.lower()
+ for key, price in token_prices.items():
+ if key.lower() == symbol_lower:
+ return price
+ # Try common variants (WSOL <-> SOL, WETH <-> ETH, etc.)
+ variants = {
+ "sol": ["wsol", "wrapped sol"],
+ "wsol": ["sol"],
+ "eth": ["weth", "wrapped eth"],
+ "weth": ["eth"],
+ }
+ for variant in variants.get(symbol_lower, []):
+ for key, price in token_prices.items():
+ if key.lower() == variant:
+ return price
+ return default
+
+ quote_price_f = get_price(quote_symbol, 1.0)
+ base_price_f = get_price(base_symbol, 0)
+
# Current holdings
if base_amount or quote_amount:
try:
@@ -1813,56 +2042,66 @@ def _format_position_detail(pos: dict, token_cache: dict = None, detailed: bool
except (ValueError, TypeError):
pass
- # Value information from pnl_summary
+ # Value information from pnl_summary - convert to USD
initial_value = pnl_summary.get('initial_value_quote')
current_value = pnl_summary.get('current_lp_value_quote') or pnl_summary.get('current_total_value_quote')
if initial_value and current_value:
try:
- lines.append(f"💵 Value: ${float(current_value):.2f} (initial: ${float(initial_value):.2f})")
+ initial_usd = float(initial_value) * quote_price_f
+ current_usd = float(current_value) * quote_price_f
+ lines.append(f"💵 Value: ${current_usd:.2f} (initial: ${initial_usd:.2f})")
except (ValueError, TypeError):
pass
lines.append("") # Separator
lines.append("━━━ Performance ━━━")
- # PnL from pnl_summary
+ # PnL from pnl_summary - convert to USD
total_pnl = pnl_summary.get('total_pnl_quote')
total_pnl_pct = pnl_summary.get('total_pnl_pct')
if total_pnl is not None:
try:
- pnl_val = float(total_pnl)
+ pnl_val = float(total_pnl) * quote_price_f
pnl_pct = float(total_pnl_pct) if total_pnl_pct else 0
emoji = "📈" if pnl_val >= 0 else "📉"
sign = "+" if pnl_val >= 0 else ""
- lines.append(f"{emoji} PnL: {sign}${pnl_val:.4f} ({sign}{pnl_pct:.4f}%)")
+ lines.append(f"{emoji} PnL: {sign}${pnl_val:.2f} ({sign}{pnl_pct:.2f}%)")
except (ValueError, TypeError):
pass
- # Impermanent loss
+ # Impermanent loss - convert to USD
il = pnl_summary.get('impermanent_loss_quote')
if il is not None:
try:
- il_val = float(il)
+ il_val = float(il) * quote_price_f
if il_val != 0:
- lines.append(f"⚠️ IL: ${il_val:.4f}")
- except (ValueError, TypeError):
- pass
-
- # Fees earned
- total_fees = pnl_summary.get('total_fees_value_quote')
- if total_fees is not None:
- try:
- fees_val = float(total_fees)
- lines.append(f"🎁 Fees earned: ${fees_val:.4f}")
+ lines.append(f"⚠️ IL: ${il_val:.2f}")
except (ValueError, TypeError):
pass
- # Pending fees
+ # Fees - show pending and collected separately in USD
try:
- base_fee_f = float(base_fee) if base_fee else 0
- quote_fee_f = float(quote_fee) if quote_fee else 0
- if base_fee_f > 0 or quote_fee_f > 0:
- lines.append(f"⏳ Pending: {format_amount(base_fee_f)} {base_symbol} / {format_amount(quote_fee_f)} {quote_symbol}")
+ # Get fee amounts
+ base_fee_pending = float(pos.get('base_fee_pending', 0) or 0)
+ quote_fee_pending = float(pos.get('quote_fee_pending', 0) or 0)
+ base_fee_collected = float(pos.get('base_fee_collected', 0) or 0)
+ quote_fee_collected = float(pos.get('quote_fee_collected', 0) or 0)
+
+ # Convert to USD
+ pending_usd = (base_fee_pending * base_price_f) + (quote_fee_pending * quote_price_f)
+ collected_usd = (base_fee_collected * base_price_f) + (quote_fee_collected * quote_price_f)
+
+ # Show pending fees (available to collect)
+ if pending_usd > 0.01:
+ lines.append(f"🎁 Pending fees: ${pending_usd:.2f}")
+
+ # Show collected fees (already claimed)
+ if collected_usd > 0.01:
+ lines.append(f"💰 Collected fees: ${collected_usd:.2f}")
+
+ # If no fees at all, show zero
+ if pending_usd <= 0.01 and collected_usd <= 0.01:
+ lines.append(f"💰 Fees: $0.00")
except (ValueError, TypeError):
pass
@@ -1942,13 +2181,14 @@ def _format_position_detail(pos: dict, token_cache: dict = None, detailed: bool
async def handle_manage_positions(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Display manage positions menu with all active LP positions"""
try:
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
if not hasattr(client, 'gateway_clmm'):
raise ValueError("Gateway CLMM not available")
# Fetch token cache for symbol resolution
- token_cache = await get_token_cache_from_gateway()
+ token_cache = await get_token_cache_from_gateway(chat_id=chat_id)
context.user_data["token_cache"] = token_cache
# Fetch all open positions
@@ -2071,13 +2311,17 @@ async def handle_pos_view(update: Update, context: ContextTypes.DEFAULT_TYPE, po
return
# Get token cache (fetch if not available)
+ chat_id = update.effective_chat.id
token_cache = context.user_data.get("token_cache")
if not token_cache:
- token_cache = await get_token_cache_from_gateway()
+ token_cache = await get_token_cache_from_gateway(chat_id=chat_id)
context.user_data["token_cache"] = token_cache
+ # Get token prices for USD conversion
+ token_prices = context.user_data.get("token_prices", {})
+
# Format detailed view with full information
- detail = _format_position_detail(pos, token_cache=token_cache, detailed=True)
+ detail = _format_position_detail(pos, token_cache=token_cache, detailed=True, token_prices=token_prices)
message = r"📍 *Position Details*" + "\n\n"
message += escape_markdown_v2(detail)
@@ -2186,7 +2430,8 @@ async def handle_pos_collect_fees(update: Update, context: ContextTypes.DEFAULT_
reply_markup=None
)
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
if not hasattr(client, 'gateway_clmm'):
raise ValueError("Gateway CLMM not available")
@@ -2196,7 +2441,7 @@ async def handle_pos_collect_fees(update: Update, context: ContextTypes.DEFAULT_
network = pos.get('network', 'solana-mainnet-beta')
position_address = pos.get('position_address', pos.get('nft_id', ''))
- # Call collect fees with 10s timeout - Solana should be fast
+ # Call collect fees with 30s timeout
try:
result = await asyncio.wait_for(
client.gateway_clmm.collect_fees(
@@ -2204,7 +2449,7 @@ async def handle_pos_collect_fees(update: Update, context: ContextTypes.DEFAULT_
network=network,
position_address=position_address
),
- timeout=10.0
+ timeout=30.0
)
except asyncio.TimeoutError:
raise TimeoutError("Operation timed out. Check your connection to the backend.")
@@ -2214,14 +2459,14 @@ async def handle_pos_collect_fees(update: Update, context: ContextTypes.DEFAULT_
[InlineKeyboardButton("« Back", callback_data="dex:liquidity")]
])
- if result:
- # Invalidate position cache so next view fetches fresh data with 0 fees
- # Also invalidate balances since collected fees go to wallet
- invalidate_cache(context.user_data, "positions", "balances")
- # Also clear the local position caches used for quick lookups
- context.user_data.pop("positions_cache", None)
- context.user_data.pop("lp_positions_cache", None)
+ # Always invalidate caches after API call - even if result is empty,
+ # the transaction was sent and we need fresh data
+ invalidate_cache(context.user_data, "positions", "balances")
+ # Also clear the local position caches used for quick lookups
+ context.user_data.pop("positions_cache", None)
+ context.user_data.pop("lp_positions_cache", None)
+ if result:
success_msg = f"✅ *Fees collected from {escape_markdown_v2(pair)}\\!*"
if isinstance(result, dict):
tx_hash = result.get('tx_hash') or result.get('txHash') or result.get('signature')
@@ -2275,9 +2520,10 @@ async def handle_pos_close_confirm(update: Update, context: ContextTypes.DEFAULT
await update.callback_query.answer("Position not found. Please refresh.")
return
- # Get token cache for symbol resolution
+ # Get token cache and prices for display
token_cache = context.user_data.get("token_cache") or {}
- detail = _format_position_detail(pos, token_cache=token_cache, detailed=True)
+ token_prices = context.user_data.get("token_prices", {})
+ detail = _format_position_detail(pos, token_cache=token_cache, detailed=True, token_prices=token_prices)
message = r"⚠️ *Close Position?*" + "\n\n"
message += escape_markdown_v2(detail) + "\n\n"
@@ -2312,9 +2558,10 @@ async def handle_pos_close_execute(update: Update, context: ContextTypes.DEFAULT
await update.callback_query.answer("Position not found. Please refresh.")
return
- # Get token cache for symbol resolution
+ # Get token cache and prices for display
token_cache = context.user_data.get("token_cache") or {}
- detail = _format_position_detail(pos, token_cache=token_cache, detailed=True)
+ token_prices = context.user_data.get("token_prices", {})
+ detail = _format_position_detail(pos, token_cache=token_cache, detailed=True, token_prices=token_prices)
# Immediately update message to show closing status (remove keyboard)
closing_msg = r"⏳ *Closing Position\.\.\.*" + "\n\n"
@@ -2327,7 +2574,8 @@ async def handle_pos_close_execute(update: Update, context: ContextTypes.DEFAULT
parse_mode="MarkdownV2"
)
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
if not hasattr(client, 'gateway_clmm'):
raise ValueError("Gateway CLMM not available")
@@ -2413,7 +2661,8 @@ async def process_position_list(
network = parts[1]
pool_address = parts[2]
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
if not hasattr(client, 'gateway_clmm'):
raise ValueError("Gateway CLMM not available")
@@ -2507,6 +2756,109 @@ def _calculate_max_range(current_price: float, bin_step: int, max_bins: int = 41
return None, None
+def _bins_to_percent(bin_step: int, num_bins: int) -> float:
+ """Convert number of bins to percentage change from current price.
+
+ Args:
+ bin_step: Pool bin step in basis points (e.g., 80 = 0.8%)
+ num_bins: Number of bins from current price
+
+ Returns:
+ Percentage change (e.g., 17.3 for 17.3%)
+ """
+ if not bin_step or not num_bins:
+ return 0.0
+ step_multiplier = 1 + (bin_step / 10000)
+ return (step_multiplier ** num_bins - 1) * 100
+
+
+def _percent_to_bins(bin_step: int, percent: float) -> int:
+ """Convert percentage change to number of bins.
+
+ Args:
+ bin_step: Pool bin step in basis points
+ percent: Percentage change (e.g., 17.3 for 17.3%)
+
+ Returns:
+ Number of bins (rounded)
+ """
+ if not bin_step or not percent:
+ return 0
+ import math
+ step_multiplier = 1 + (bin_step / 10000)
+ # percent% = (multiplier^n - 1) * 100
+ # (percent/100 + 1) = multiplier^n
+ # n = log(percent/100 + 1) / log(multiplier)
+ try:
+ n = math.log(abs(percent) / 100 + 1) / math.log(step_multiplier)
+ return int(round(n))
+ except (ValueError, ZeroDivisionError):
+ return 0
+
+
+def _price_to_percent(current_price: float, target_price: float) -> float:
+ """Calculate percentage difference from current price to target price.
+
+ Args:
+ current_price: Current pool price
+ target_price: Target price (lower or upper)
+
+ Returns:
+ Percentage difference (negative for lower, positive for upper)
+ """
+ if not current_price or not target_price:
+ return 0.0
+ return ((target_price / current_price) - 1) * 100
+
+
+def _validate_bin_range(lower_price: float, upper_price: float, current_price: float, bin_step: int, max_bins: int = 68) -> tuple:
+ """Validate that the price range doesn't exceed max bins.
+
+ Args:
+ lower_price: Lower price bound
+ upper_price: Upper price bound
+ current_price: Current pool price
+ bin_step: Pool bin step in basis points
+ max_bins: Maximum allowed bins (default 68 to be safe, max is 69)
+
+ Returns:
+ Tuple of (is_valid, total_bins, error_message)
+ """
+ if not all([lower_price, upper_price, current_price, bin_step]):
+ return True, 0, None
+
+ try:
+ step_multiplier = 1 + (bin_step / 10000)
+ import math
+
+ # Calculate bins from current price to each bound
+ lower_bins = abs(math.log(lower_price / current_price) / math.log(step_multiplier))
+ upper_bins = abs(math.log(upper_price / current_price) / math.log(step_multiplier))
+ total_bins = int(round(lower_bins + upper_bins)) + 1 # +1 for active bin
+
+ if total_bins > max_bins:
+ max_pct = _bins_to_percent(bin_step, max_bins // 2)
+ return False, total_bins, f"Range too wide: {total_bins} bins (max {max_bins}). Try L:{max_pct:.1f}% U:{max_pct:.1f}%"
+
+ return True, total_bins, None
+ except Exception:
+ return True, 0, None
+
+
+def _get_default_range_percent(bin_step: int, num_bins: int = 20) -> tuple:
+ """Get default lower and upper percentages based on bin_step and number of bins.
+
+ Args:
+ bin_step: Pool bin step in basis points
+ num_bins: Number of bins each side (default 20)
+
+ Returns:
+ Tuple of (lower_pct, upper_pct) e.g., (-17.3, 17.3)
+ """
+ pct = _bins_to_percent(bin_step, num_bins)
+ return (-pct, pct)
+
+
async def _fetch_token_balances(client, network: str, base_symbol: str, quote_symbol: str) -> dict:
"""Fetch wallet balances for base and quote tokens
@@ -2737,7 +3089,8 @@ async def show_add_position_menu(
if base_token and quote_token:
token_cache = context.user_data.get("token_cache")
if not token_cache:
- token_cache = await get_token_cache_from_gateway()
+ chat_id = update.effective_chat.id
+ token_cache = await get_token_cache_from_gateway(chat_id=chat_id)
context.user_data["token_cache"] = token_cache
base_symbol = resolve_token_symbol(base_token, token_cache)
@@ -2765,19 +3118,42 @@ async def show_add_position_menu(
base_symbol = resolve_token_symbol(base_token, token_cache) if base_token else 'BASE'
quote_symbol = resolve_token_symbol(quote_token, token_cache) if quote_token else 'QUOTE'
- # Calculate max range (69 bins) and auto-fill if not set
+ # Calculate max range (20 bins each side = 41 total) and auto-fill if not set
suggested_lower, suggested_upper = None, None
+ default_lower_pct, default_upper_pct = None, None
if current_price and bin_step:
try:
+ current_price_float = float(current_price)
+ bin_step_int = int(bin_step)
+
+ # Get default percentages for 20 bins each side
+ default_lower_pct, default_upper_pct = _get_default_range_percent(bin_step_int, 20)
+
suggested_lower, suggested_upper = _calculate_max_range(
- float(current_price),
- int(bin_step)
+ current_price_float,
+ bin_step_int
)
- # Auto-fill if empty
+ # Auto-fill if empty - store both price and percentage
if suggested_lower and not params.get('lower_price'):
- params['lower_price'] = f"{suggested_lower:.6f}"
+ params['lower_price'] = f"{suggested_lower:.10f}".rstrip('0').rstrip('.')
+ params['lower_pct'] = default_lower_pct
if suggested_upper and not params.get('upper_price'):
- params['upper_price'] = f"{suggested_upper:.6f}"
+ params['upper_price'] = f"{suggested_upper:.10f}".rstrip('0').rstrip('.')
+ params['upper_pct'] = default_upper_pct
+
+ # Calculate percentages for existing prices if not set
+ if params.get('lower_price') and not params.get('lower_pct'):
+ try:
+ params['lower_pct'] = _price_to_percent(current_price_float, float(params['lower_price']))
+ except (ValueError, TypeError):
+ pass
+ if params.get('upper_price') and not params.get('upper_pct'):
+ try:
+ params['upper_pct'] = _price_to_percent(current_price_float, float(params['upper_price']))
+ except (ValueError, TypeError):
+ pass
+
+ context.user_data["add_position_params"] = params
except (ValueError, TypeError) as e:
logger.warning(f"Failed to calculate range: {e}")
@@ -2831,8 +3207,13 @@ async def show_add_position_menu(
help_text += r"Type multiple values at once:" + "\n"
help_text += r"• `l:0\.89 \- u:1\.47`" + "\n"
+ help_text += r"• `l:5% \- u:10%` _\(% from price\)_" + "\n"
help_text += r"• `l:0\.89 \- u:1\.47 \- b:20% \- q:20%`" + "\n\n"
+ help_text += r"*Price %:* `l:5%` = \-5% from price" + "\n"
+ help_text += r" `u:10%` = \+10% from price" + "\n"
+ help_text += r" `l:\-3%` `u:\+15%` _explicit signs_" + "\n\n"
+
help_text += r"Keys: `l`=lower, `u`=upper, `b`=base, `q`=quote" + "\n\n"
help_text += r"━━━━━━━━━━━━━━━━━━━━" + "\n"
@@ -2864,7 +3245,8 @@ async def show_add_position_menu(
balance_cache_key = f"token_balances_{network}_{base_symbol}_{quote_symbol}"
balances = get_cached(context.user_data, balance_cache_key, ttl=DEFAULT_CACHE_TTL)
if balances is None:
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
balances = await _fetch_token_balances(client, network, base_symbol, quote_symbol)
set_cached(context.user_data, balance_cache_key, balances)
@@ -2886,13 +3268,85 @@ async def show_add_position_menu(
except Exception as e:
logger.warning(f"Could not fetch token balances: {e}")
+ balances = context.user_data.get("token_balances", {"base_balance": 0, "quote_balance": 0})
+
+ # ========== POSITION SUMMARY ==========
+ # Show the position that will be created with current parameters
+ help_text += "\n" + r"━━━ Position Preview ━━━" + "\n"
+
+ # Calculate actual amounts from percentages
+ base_amount_str = params.get('amount_base', '10%')
+ quote_amount_str = params.get('amount_quote', '10%')
+
+ try:
+ if base_amount_str.endswith('%'):
+ base_pct = float(base_amount_str[:-1])
+ base_amount = balances.get("base_balance", 0) * base_pct / 100
+ else:
+ base_amount = float(base_amount_str) if base_amount_str else 0
+ except (ValueError, TypeError):
+ base_amount = 0
+
+ try:
+ if quote_amount_str.endswith('%'):
+ quote_pct = float(quote_amount_str[:-1])
+ quote_amount = balances.get("quote_balance", 0) * quote_pct / 100
+ else:
+ quote_amount = float(quote_amount_str) if quote_amount_str else 0
+ except (ValueError, TypeError):
+ quote_amount = 0
+
+ # Price range with percentages
+ lower_pct = params.get('lower_pct')
+ upper_pct = params.get('upper_pct')
+ lower_price_str = params.get('lower_price', '')
+ upper_price_str = params.get('upper_price', '')
+
+ if lower_price_str and upper_price_str:
+ try:
+ lower_p = float(lower_price_str)
+ upper_p = float(upper_price_str)
+
+ # Format prices nicely
+ if lower_p >= 1:
+ l_str = f"{lower_p:.4f}"
+ else:
+ l_str = f"{lower_p:.6f}"
+ if upper_p >= 1:
+ u_str = f"{upper_p:.4f}"
+ else:
+ u_str = f"{upper_p:.6f}"
+
+ # Show range with percentages
+ l_pct_str = f"{lower_pct:+.1f}%" if lower_pct is not None else ""
+ u_pct_str = f"{upper_pct:+.1f}%" if upper_pct is not None else ""
+ help_text += f"📉 *L:* `{escape_markdown_v2(l_str)}` _{escape_markdown_v2(l_pct_str)}_\n"
+ help_text += f"📈 *U:* `{escape_markdown_v2(u_str)}` _{escape_markdown_v2(u_pct_str)}_\n"
+
+ # Validate bin range
+ if current_price and bin_step:
+ is_valid, total_bins, error_msg = _validate_bin_range(
+ lower_p, upper_p, float(current_price), int(bin_step), max_bins=68
+ )
+ if not is_valid:
+ help_text += f"⚠️ _{escape_markdown_v2(error_msg)}_\n"
+ elif total_bins > 0:
+ help_text += f"📊 *Bins:* `{total_bins}` _\\(max 68\\)_\n"
+ except (ValueError, TypeError):
+ pass
+
+ # Show amounts
+ help_text += f"💰 *{escape_markdown_v2(base_symbol)}:* `{escape_markdown_v2(_format_number(base_amount))}`\n"
+ help_text += f"💵 *{escape_markdown_v2(quote_symbol)}:* `{escape_markdown_v2(_format_number(quote_amount))}`\n"
# NOTE: ASCII visualization is added AFTER we know if chart image is available
# This is done below, after chart_bytes is generated
- # Build keyboard - values shown in buttons, not in message body
- lower_display = params.get('lower_price', '—')[:8] if params.get('lower_price') else '—'
- upper_display = params.get('upper_price', '—')[:8] if params.get('upper_price') else '—'
+ # Build keyboard - show percentages in buttons for L/U
+ lower_pct = params.get('lower_pct')
+ upper_pct = params.get('upper_pct')
+ lower_display = f"{lower_pct:.1f}%" if lower_pct is not None else (params.get('lower_price', '—')[:8] if params.get('lower_price') else '—')
+ upper_display = f"+{upper_pct:.1f}%" if upper_pct is not None and upper_pct >= 0 else (f"{upper_pct:.1f}%" if upper_pct is not None else (params.get('upper_price', '—')[:8] if params.get('upper_price') else '—'))
base_display = params.get('amount_base') or '10%'
quote_display = params.get('amount_quote') or '10%'
strategy_display = params.get('strategy_type', '0')
@@ -2904,11 +3358,11 @@ async def show_add_position_menu(
keyboard = [
[
InlineKeyboardButton(
- f"📉 Lower: {lower_display}",
+ f"📉 L: {lower_display}",
callback_data="dex:pos_set_lower"
),
InlineKeyboardButton(
- f"📈 Upper: {upper_display}",
+ f"📈 U: {upper_display}",
callback_data="dex:pos_set_upper"
)
],
@@ -2974,20 +3428,32 @@ async def show_add_position_menu(
# Check if we have a stored menu message we can edit
stored_menu_msg_id = context.user_data.get("add_position_menu_msg_id")
stored_menu_chat_id = context.user_data.get("add_position_menu_chat_id")
+ stored_menu_is_photo = context.user_data.get("add_position_menu_is_photo", False)
if send_new or not update.callback_query:
chat = update.message.chat if update.message else update.callback_query.message.chat
# Try to edit stored message if available (for text input updates)
- if stored_menu_msg_id and stored_menu_chat_id and not chart_bytes:
+ if stored_menu_msg_id and stored_menu_chat_id:
try:
- await update.get_bot().edit_message_text(
- chat_id=stored_menu_chat_id,
- message_id=stored_menu_msg_id,
- text=help_text,
- parse_mode="MarkdownV2",
- reply_markup=reply_markup
- )
+ if stored_menu_is_photo:
+ # Edit caption for photo message
+ await update.get_bot().edit_message_caption(
+ chat_id=stored_menu_chat_id,
+ message_id=stored_menu_msg_id,
+ caption=help_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ else:
+ # Edit text for regular message
+ await update.get_bot().edit_message_text(
+ chat_id=stored_menu_chat_id,
+ message_id=stored_menu_msg_id,
+ text=help_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
return
except Exception as e:
if "not modified" not in str(e).lower():
@@ -3008,15 +3474,18 @@ async def show_add_position_menu(
)
context.user_data["add_position_menu_msg_id"] = sent_msg.message_id
context.user_data["add_position_menu_chat_id"] = chat.id
+ context.user_data["add_position_menu_is_photo"] = True
except Exception as e:
logger.warning(f"Failed to send chart: {e}")
sent_msg = await chat.send_message(text=help_text, parse_mode="MarkdownV2", reply_markup=reply_markup)
context.user_data["add_position_menu_msg_id"] = sent_msg.message_id
context.user_data["add_position_menu_chat_id"] = chat.id
+ context.user_data["add_position_menu_is_photo"] = False
else:
sent_msg = await chat.send_message(text=help_text, parse_mode="MarkdownV2", reply_markup=reply_markup)
context.user_data["add_position_menu_msg_id"] = sent_msg.message_id
context.user_data["add_position_menu_chat_id"] = chat.id
+ context.user_data["add_position_menu_is_photo"] = False
else:
# Try to edit caption if it's a photo, otherwise edit text
# Prioritize editing over delete+resend to avoid message flicker
@@ -3025,6 +3494,7 @@ async def show_add_position_menu(
# Store message ID for future text input edits
context.user_data["add_position_menu_msg_id"] = msg.message_id
context.user_data["add_position_menu_chat_id"] = msg.chat.id
+ context.user_data["add_position_menu_is_photo"] = bool(msg.photo)
try:
if msg.photo:
@@ -3104,7 +3574,8 @@ async def handle_pos_refresh(update: Update, context: ContextTypes.DEFAULT_TYPE)
# Refetch pool info
if pool_address:
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
pool_info = await _fetch_pool_info(client, pool_address, connector)
set_cached(context.user_data, pool_cache_key, pool_info)
context.user_data["selected_pool_info"] = pool_info
@@ -3365,6 +3836,28 @@ async def handle_pos_add_confirm(update: Update, context: ContextTypes.DEFAULT_T
if not amount_base_str and not amount_quote_str:
raise ValueError("Need at least one amount (base or quote)")
+ # Validate bin range (max 68 bins to be safe)
+ selected_pool = context.user_data.get("selected_pool", {})
+ pool_info = context.user_data.get("selected_pool_info", {})
+ current_price = pool_info.get('price') or selected_pool.get('current_price') or selected_pool.get('price')
+ bin_step = pool_info.get('bin_step') or selected_pool.get('bin_step')
+
+ if current_price and bin_step:
+ try:
+ is_valid, total_bins, error_msg = _validate_bin_range(
+ float(lower_price),
+ float(upper_price),
+ float(current_price),
+ int(bin_step),
+ max_bins=68
+ )
+ if not is_valid:
+ raise ValueError(error_msg)
+ except ValueError:
+ raise
+ except Exception as e:
+ logger.warning(f"Could not validate bin range: {e}")
+
# Show loading message immediately
await query.answer()
loading_msg = (
@@ -3376,14 +3869,15 @@ async def handle_pos_add_confirm(update: Update, context: ContextTypes.DEFAULT_T
# Edit the current message to show loading state
try:
- if query.message.photo:
+ if getattr(query.message, 'photo', None):
await query.message.edit_caption(caption=loading_msg, parse_mode="MarkdownV2")
else:
await query.message.edit_text(loading_msg, parse_mode="MarkdownV2")
except Exception:
pass
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
if not hasattr(client, 'gateway_clmm'):
raise ValueError("Gateway CLMM not available")
@@ -3401,11 +3895,11 @@ async def handle_pos_add_confirm(update: Update, context: ContextTypes.DEFAULT_T
if amount_base is None and amount_quote is None:
raise ValueError("Invalid amounts. Use '10%' for percentage or '100' for absolute value.")
- # Check if using percentage with no balance
- if amount_base_str and amount_base_str.endswith('%') and base_balance <= 0:
- raise ValueError(f"Cannot use percentage - no base token balance found")
- if amount_quote_str and amount_quote_str.endswith('%') and quote_balance <= 0:
- raise ValueError(f"Cannot use percentage - no quote token balance found")
+ # Check if we have at least one non-zero amount
+ base_is_zero = amount_base is None or amount_base == 0
+ quote_is_zero = amount_quote is None or amount_quote == 0
+ if base_is_zero and quote_is_zero:
+ raise ValueError("Both amounts are 0. Need at least one token to add liquidity.")
# Build extra_params for strategy type
extra_params = {"strategyType": strategy_type}
@@ -3463,7 +3957,7 @@ async def handle_pos_add_confirm(update: Update, context: ContextTypes.DEFAULT_T
# Edit the loading message with success result
try:
- if query.message.photo:
+ if getattr(query.message, 'photo', None):
await query.message.edit_caption(caption=pos_info, parse_mode="MarkdownV2", reply_markup=reply_markup)
else:
await query.message.edit_text(pos_info, parse_mode="MarkdownV2", reply_markup=reply_markup)
@@ -3476,7 +3970,7 @@ async def handle_pos_add_confirm(update: Update, context: ContextTypes.DEFAULT_T
keyboard = [[InlineKeyboardButton("« Back", callback_data="dex:pool_detail_refresh")]]
reply_markup = InlineKeyboardMarkup(keyboard)
try:
- if query.message.photo:
+ if getattr(query.message, 'photo', None):
await query.message.edit_caption(caption=error_message, parse_mode="MarkdownV2", reply_markup=reply_markup)
else:
await query.message.edit_text(error_message, parse_mode="MarkdownV2", reply_markup=reply_markup)
@@ -3488,7 +3982,7 @@ async def handle_pos_add_confirm(update: Update, context: ContextTypes.DEFAULT_T
# TEXT INPUT PROCESSORS FOR POSITION
# ============================================
-def _parse_multi_field_input(user_input: str) -> dict:
+def _parse_multi_field_input(user_input: str, current_price: float = None) -> dict:
"""
Parse multi-field input for position parameters.
@@ -3496,6 +3990,8 @@ def _parse_multi_field_input(user_input: str) -> dict:
- l:0.8892 - u:1.47
- l:0.8892 - u:1.47 - b:20% - q:20%
- L:100 U:200 B:10% Q:10% (spaces without dashes also work)
+ - l:-5% - u:+10% (percentage relative to current price)
+ - l:5% - u:10% (shorthand: l:X% = -X%, u:X% = +X%)
Keys (case-insensitive):
- l, lower = lower_price
@@ -3535,7 +4031,47 @@ def _parse_multi_field_input(user_input: str) -> dict:
key = key_val[0].strip().lower()
value = key_val[1].strip()
if key in key_map and value:
- result[key_map[key]] = value
+ field_name = key_map[key]
+
+ # Handle percentage for lower_price and upper_price
+ if field_name in ('lower_price', 'upper_price') and value.endswith('%') and current_price:
+ try:
+ # Parse percentage value (supports +10%, -5%, or just 5%)
+ pct_str = value[:-1].strip()
+ pct = float(pct_str)
+
+ # Default behavior: lower uses negative, upper uses positive
+ if field_name == 'lower_price':
+ # l:5% means -5%, l:-5% means -5%, l:+5% means +5%
+ if not pct_str.startswith('+') and not pct_str.startswith('-'):
+ pct = -abs(pct) # Default to negative for lower
+ # Store the percentage for display
+ result['lower_pct'] = pct
+ else: # upper_price
+ # u:5% means +5%, u:+5% means +5%, u:-5% means -5%
+ if not pct_str.startswith('+') and not pct_str.startswith('-'):
+ pct = abs(pct) # Default to positive for upper
+ # Store the percentage for display
+ result['upper_pct'] = pct
+
+ # Calculate price based on percentage
+ calculated_price = current_price * (1 + pct / 100)
+ value = f"{calculated_price:.10f}".rstrip('0').rstrip('.')
+ except (ValueError, TypeError):
+ pass # Keep original value if parsing fails
+ elif field_name in ('lower_price', 'upper_price') and not value.endswith('%') and current_price:
+ # Calculate percentage from absolute price
+ try:
+ price_val = float(value)
+ pct = _price_to_percent(current_price, price_val)
+ if field_name == 'lower_price':
+ result['lower_pct'] = pct
+ else:
+ result['upper_pct'] = pct
+ except (ValueError, TypeError):
+ pass
+
+ result[field_name] = value
return result
@@ -3549,33 +4085,43 @@ async def process_add_position(
Supports two input formats:
1. Multi-field: l:0.8892 - u:1.47 - b:20% - q:20% (updates params and shows menu)
+ - Also supports percentage for L/U: l:-5% - u:+10% (relative to current price)
2. Full input: pool_address lower_price upper_price amount_base amount_quote (executes)
"""
try:
+ # Get current price for percentage calculations
+ selected_pool = context.user_data.get("selected_pool", {})
+ pool_info = context.user_data.get("selected_pool_info", {})
+ current_price = pool_info.get('price') or selected_pool.get('current_price') or selected_pool.get('price')
+
+ try:
+ current_price_float = float(current_price) if current_price else None
+ except (ValueError, TypeError):
+ current_price_float = None
+
# First, check if this is multi-field input (quick updates)
- multi_updates = _parse_multi_field_input(user_input)
+ multi_updates = _parse_multi_field_input(user_input, current_price_float)
if multi_updates:
params = context.user_data.get("add_position_params", {})
params.update(multi_updates)
context.user_data["add_position_params"] = params
- # Build confirmation message
- updated_fields = []
- if 'lower_price' in multi_updates:
- updated_fields.append(f"L: {multi_updates['lower_price']}")
- if 'upper_price' in multi_updates:
- updated_fields.append(f"U: {multi_updates['upper_price']}")
- if 'amount_base' in multi_updates:
- updated_fields.append(f"Base: {multi_updates['amount_base']}")
- if 'amount_quote' in multi_updates:
- updated_fields.append(f"Quote: {multi_updates['amount_quote']}")
-
- success_msg = escape_markdown_v2(f"✅ Updated: {', '.join(updated_fields)}")
- await update.message.reply_text(success_msg, parse_mode="MarkdownV2")
-
- # Refresh pool detail view with updated chart
- selected_pool = context.user_data.get("selected_pool", {})
- if selected_pool:
+ # Delete user's input message to keep chat clean
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ # Edit existing menu message instead of sending new one
+ stored_msg_id = context.user_data.get("add_position_menu_msg_id") or context.user_data.get("pool_detail_message_id")
+ stored_chat_id = context.user_data.get("add_position_menu_chat_id") or context.user_data.get("pool_detail_chat_id")
+
+ if stored_msg_id and stored_chat_id and selected_pool:
+ # Use show_add_position_menu which handles editing properly
+ # Create a minimal update-like object to simulate callback
+ await show_add_position_menu(update, context, send_new=False)
+ elif selected_pool:
+ # Fallback: show pool detail (will send new message)
await _show_pool_detail(update, context, selected_pool, from_callback=False)
return
@@ -3596,7 +4142,8 @@ async def process_add_position(
connector = params.get("connector", "meteora")
network = params.get("network", "solana-mainnet-beta")
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
if not hasattr(client, 'gateway_clmm'):
raise ValueError("Gateway CLMM not available")
diff --git a/handlers/dex/swap.py b/handlers/dex/swap.py
index 4c0b44a..1120da8 100644
--- a/handlers/dex/swap.py
+++ b/handlers/dex/swap.py
@@ -19,6 +19,7 @@
get_dex_connector,
get_dex_last_swap,
set_dex_last_swap,
+ get_all_enabled_networks,
DEFAULT_DEX_NETWORK,
)
from servers import get_client
@@ -37,7 +38,7 @@
build_filter_selection_keyboard,
HISTORY_FILTERS,
)
-from .menu import _fetch_balances
+from .menu import _fetch_balances, _filter_balances_by_networks
logger = logging.getLogger(__name__)
@@ -251,7 +252,8 @@ async def handle_swap_refresh(update: Update, context: ContextTypes.DEFAULT_TYPE
async def _fetch_quotes_background(
context: ContextTypes.DEFAULT_TYPE,
message,
- params: dict
+ params: dict,
+ chat_id: int = None
) -> None:
"""Fetch BUY/SELL quotes and balances in background and update the message"""
try:
@@ -264,7 +266,7 @@ async def _fetch_quotes_background(
if not all([connector, network, trading_pair, amount]):
return
- client = await get_client()
+ client = await get_client(chat_id)
# Fetch balances in parallel with quotes
async def fetch_balances_safe():
@@ -419,6 +421,10 @@ def _build_swap_menu_text(user_data: dict, params: dict, quote_result: dict = No
help_text += r"━━━ Balance ━━━" + "\n"
try:
gateway_data = get_cached(user_data, "gateway_balances", ttl=120)
+ # Filter by enabled networks
+ enabled_networks = get_all_enabled_networks(user_data)
+ if enabled_networks and gateway_data:
+ gateway_data = _filter_balances_by_networks(gateway_data, enabled_networks)
if gateway_data and gateway_data.get("balances_by_network"):
network_key = network.split("-")[0].lower() if network else ""
balances_found = {"base_balance": 0, "base_value": 0, "quote_balance": 0, "quote_value": 0}
@@ -511,13 +517,14 @@ async def show_swap_menu(update: Update, context: ContextTypes.DEFAULT_TYPE, sen
quote_result: Optional quote result to display inline
auto_quote: If True, fetch quote in background automatically
"""
+ chat_id = update.effective_chat.id
params = context.user_data.get("swap_params", {})
# Fetch recent swaps if not cached
swaps = get_cached(context.user_data, "recent_swaps", ttl=60)
if swaps is None:
try:
- client = await get_client()
+ client = await get_client(chat_id)
swaps = await _fetch_recent_swaps(client, limit=5)
set_cached(context.user_data, "recent_swaps", swaps)
except Exception as e:
@@ -552,7 +559,7 @@ async def show_swap_menu(update: Update, context: ContextTypes.DEFAULT_TYPE, sen
# Launch background quote fetch if no quote yet and auto_quote is enabled
if auto_quote and quote_result is None and message:
- asyncio.create_task(_fetch_quotes_background(context, message, params))
+ asyncio.create_task(_fetch_quotes_background(context, message, params, chat_id))
# ============================================
@@ -577,11 +584,12 @@ async def handle_swap_toggle_side(update: Update, context: ContextTypes.DEFAULT_
async def handle_swap_set_connector(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Show available router connectors for selection"""
+ chat_id = update.effective_chat.id
params = context.user_data.get("swap_params", {})
network = params.get("network", "solana-mainnet-beta")
try:
- client = await get_client()
+ client = await get_client(chat_id)
cache_key = "router_connectors"
connectors = get_cached(context.user_data, cache_key, ttl=300)
@@ -652,8 +660,9 @@ async def handle_swap_connector_select(update: Update, context: ContextTypes.DEF
async def handle_swap_set_network(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Show available networks for selection"""
+ chat_id = update.effective_chat.id
try:
- client = await get_client()
+ client = await get_client(chat_id)
networks_cache_key = "gateway_networks"
networks = get_cached(context.user_data, networks_cache_key, ttl=300)
@@ -809,6 +818,7 @@ async def handle_swap_set_slippage(update: Update, context: ContextTypes.DEFAULT
async def handle_swap_get_quote(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Get quote for both BUY and SELL in parallel, display with spread"""
+ chat_id = update.effective_chat.id
try:
params = context.user_data.get("swap_params", {})
@@ -821,7 +831,7 @@ async def handle_swap_get_quote(update: Update, context: ContextTypes.DEFAULT_TY
if not all([connector, network, trading_pair, amount]):
raise ValueError("Missing required parameters")
- client = await get_client()
+ client = await get_client(chat_id)
# Fetch balances in parallel with quotes
async def fetch_balances_safe():
@@ -956,7 +966,8 @@ async def handle_swap_execute_confirm(update: Update, context: ContextTypes.DEFA
parse_mode="MarkdownV2"
)
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
if not hasattr(client, 'gateway_swap'):
raise ValueError("Gateway swap not available")
@@ -1073,7 +1084,8 @@ async def process_swap_status(
) -> None:
"""Process swap status check"""
try:
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
if not hasattr(client, 'gateway_swap'):
raise ValueError("Gateway swap not available")
@@ -1173,7 +1185,8 @@ async def handle_swap_history(update: Update, context: ContextTypes.DEFAULT_TYPE
else:
filters = get_history_filters(context.user_data, "swap")
- client = await get_client()
+ chat_id = update.effective_chat.id
+ client = await get_client(chat_id)
if not hasattr(client, 'gateway_swap'):
error_message = format_error_message("Gateway swap not available")
diff --git a/handlers/dex/visualizations.py b/handlers/dex/visualizations.py
index 26a2ad7..629e9f0 100644
--- a/handlers/dex/visualizations.py
+++ b/handlers/dex/visualizations.py
@@ -5,16 +5,347 @@
- Liquidity distribution charts (from CLMM bin data)
- OHLCV candlestick charts (from GeckoTerminal)
- Combined charts with OHLCV + Liquidity side-by-side
+- Base candlestick chart function (shared with grid_strike)
"""
import io
import logging
-from typing import List, Optional, Dict, Any
+from typing import List, Optional, Dict, Any, Union
from datetime import datetime
logger = logging.getLogger(__name__)
+# ==============================================
+# UNIFIED DARK THEME (shared across all charts)
+# ==============================================
+DARK_THEME = {
+ "bgcolor": "#0a0e14",
+ "paper_bgcolor": "#0a0e14",
+ "plot_bgcolor": "#131720",
+ "font_color": "#e6edf3",
+ "font_family": "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif",
+ "grid_color": "#21262d",
+ "axis_color": "#8b949e",
+ "up_color": "#10b981", # Green for bullish
+ "down_color": "#ef4444", # Red for bearish
+ "current_price_color": "#f59e0b", # Orange for current price
+ "line_color": "#3b82f6", # Blue for lines
+}
+
+
+def _normalize_candles(candles: List[Union[Dict, List]]) -> List[Dict[str, Any]]:
+ """Normalize candle data to a standard dict format.
+
+ Accepts both:
+ - List of dicts: [{"timestamp": ..., "open": ..., "high": ..., "low": ..., "close": ..., "volume": ...}]
+ - List of lists: [[timestamp, open, high, low, close, volume], ...]
+
+ Returns:
+ List of normalized candle dicts with keys: timestamp, open, high, low, close, volume
+ """
+ normalized = []
+
+ for candle in candles:
+ if isinstance(candle, dict):
+ normalized.append({
+ "timestamp": candle.get("timestamp"),
+ "open": float(candle.get("open", 0) or 0),
+ "high": float(candle.get("high", 0) or 0),
+ "low": float(candle.get("low", 0) or 0),
+ "close": float(candle.get("close", 0) or 0),
+ "volume": float(candle.get("volume", 0) or 0),
+ })
+ elif isinstance(candle, (list, tuple)) and len(candle) >= 5:
+ normalized.append({
+ "timestamp": candle[0],
+ "open": float(candle[1] or 0),
+ "high": float(candle[2] or 0),
+ "low": float(candle[3] or 0),
+ "close": float(candle[4] or 0),
+ "volume": float(candle[5] or 0) if len(candle) > 5 else 0,
+ })
+
+ return normalized
+
+
+def _parse_timestamp(raw_ts) -> Optional[datetime]:
+ """Parse timestamp from various formats to datetime."""
+ if raw_ts is None:
+ return None
+
+ try:
+ if isinstance(raw_ts, datetime):
+ return raw_ts
+ if hasattr(raw_ts, 'to_pydatetime'): # pandas Timestamp
+ return raw_ts.to_pydatetime()
+ if isinstance(raw_ts, (int, float)):
+ # Unix timestamp (seconds or milliseconds)
+ if raw_ts > 1e12: # milliseconds
+ return datetime.fromtimestamp(raw_ts / 1000)
+ else:
+ return datetime.fromtimestamp(raw_ts)
+ if isinstance(raw_ts, str) and raw_ts:
+ # Try parsing ISO format
+ if "T" in raw_ts:
+ return datetime.fromisoformat(raw_ts.replace("Z", "+00:00"))
+ else:
+ return datetime.fromisoformat(raw_ts)
+ except Exception:
+ pass
+
+ return None
+
+
+def generate_candlestick_chart(
+ candles: List[Union[Dict, List]],
+ title: str = "",
+ current_price: Optional[float] = None,
+ show_volume: bool = True,
+ width: int = 1100,
+ height: int = 600,
+ hlines: Optional[List[Dict]] = None,
+ hrects: Optional[List[Dict]] = None,
+ reverse_data: bool = False,
+) -> Optional[io.BytesIO]:
+ """Generate a candlestick chart with optional overlays.
+
+ This is the base function used by both grid_strike and DEX OHLCV charts.
+
+ Args:
+ candles: List of candle data (dicts or lists - will be normalized)
+ title: Chart title
+ current_price: Current price for horizontal line
+ show_volume: Whether to show volume subplot
+ width: Chart width in pixels
+ height: Chart height in pixels
+ hlines: List of horizontal lines to add, each dict with:
+ - y: float (required)
+ - color: str (default: blue)
+ - dash: str (solid, dash, dot, dashdot)
+ - label: str (annotation text)
+ - label_position: str (left, right)
+ hrects: List of horizontal rectangles to add, each dict with:
+ - y0: float (required)
+ - y1: float (required)
+ - color: str (fill color with alpha, e.g., "rgba(59, 130, 246, 0.15)")
+ - label: str (annotation text)
+ reverse_data: Whether to reverse data order (GeckoTerminal returns newest first)
+
+ Returns:
+ BytesIO buffer with PNG image or None if failed
+ """
+ try:
+ import plotly.graph_objects as go
+ from plotly.subplots import make_subplots
+
+ # Normalize candle data
+ normalized = _normalize_candles(candles)
+ if not normalized:
+ logger.warning("No valid candle data after normalization")
+ return None
+
+ # Reverse if needed (GeckoTerminal returns newest first)
+ if reverse_data:
+ normalized = list(reversed(normalized))
+
+ # Extract data for plotting
+ timestamps = []
+ opens = []
+ highs = []
+ lows = []
+ closes = []
+ volumes = []
+
+ for candle in normalized:
+ dt = _parse_timestamp(candle["timestamp"])
+ if dt:
+ timestamps.append(dt)
+ else:
+ timestamps.append(str(candle["timestamp"]))
+
+ opens.append(candle["open"])
+ highs.append(candle["high"])
+ lows.append(candle["low"])
+ closes.append(candle["close"])
+ volumes.append(candle["volume"])
+
+ if not timestamps:
+ logger.warning("No valid timestamps in candle data")
+ return None
+
+ # Create figure with or without volume subplot
+ if show_volume and any(v > 0 for v in volumes):
+ fig = make_subplots(
+ rows=2, cols=1,
+ shared_xaxes=True,
+ vertical_spacing=0.03,
+ row_heights=[0.75, 0.25],
+ )
+ volume_row = 2
+ else:
+ fig = go.Figure()
+ volume_row = None
+
+ # Add candlestick chart
+ candlestick = go.Candlestick(
+ x=timestamps,
+ open=opens,
+ high=highs,
+ low=lows,
+ close=closes,
+ increasing_line_color=DARK_THEME["up_color"],
+ decreasing_line_color=DARK_THEME["down_color"],
+ increasing_fillcolor=DARK_THEME["up_color"],
+ decreasing_fillcolor=DARK_THEME["down_color"],
+ name="Price"
+ )
+
+ if volume_row:
+ fig.add_trace(candlestick, row=1, col=1)
+ else:
+ fig.add_trace(candlestick)
+
+ # Add volume bars if enabled
+ if volume_row:
+ volume_colors = [
+ DARK_THEME["up_color"] if closes[i] >= opens[i] else DARK_THEME["down_color"]
+ for i in range(len(timestamps))
+ ]
+ fig.add_trace(
+ go.Bar(
+ x=timestamps,
+ y=volumes,
+ name='Volume',
+ marker_color=volume_colors,
+ opacity=0.7,
+ ),
+ row=2, col=1
+ )
+
+ # Add horizontal rectangles (grid zones, etc.)
+ if hrects:
+ for rect in hrects:
+ fig.add_hrect(
+ y0=rect.get("y0"),
+ y1=rect.get("y1"),
+ fillcolor=rect.get("color", "rgba(59, 130, 246, 0.15)"),
+ line_width=0,
+ annotation_text=rect.get("label"),
+ annotation_position="top left",
+ annotation_font=dict(
+ color=DARK_THEME["font_color"],
+ size=11
+ ) if rect.get("label") else None
+ )
+
+ # Add horizontal lines (start price, end price, limit price, etc.)
+ if hlines:
+ for hline in hlines:
+ fig.add_hline(
+ y=hline.get("y"),
+ line_dash=hline.get("dash", "solid"),
+ line_color=hline.get("color", DARK_THEME["line_color"]),
+ line_width=hline.get("width", 2),
+ annotation_text=hline.get("label"),
+ annotation_position=hline.get("label_position", "right"),
+ annotation_font=dict(
+ color=hline.get("color", DARK_THEME["line_color"]),
+ size=10
+ ) if hline.get("label") else None
+ )
+
+ # Add current price line
+ if current_price:
+ fig.add_hline(
+ y=current_price,
+ line_dash="solid",
+ line_color=DARK_THEME["current_price_color"],
+ line_width=2,
+ annotation_text=f"Current: {current_price:,.4f}",
+ annotation_position="left",
+ annotation_font=dict(
+ color=DARK_THEME["current_price_color"],
+ size=10
+ )
+ )
+
+ # Calculate height based on volume
+ actual_height = height if not volume_row else int(height * 1.2)
+
+ # Update layout with dark theme
+ fig.update_layout(
+ title=dict(
+ text=f"{title}" if title else None,
+ font=dict(
+ family=DARK_THEME["font_family"],
+ size=18,
+ color=DARK_THEME["font_color"]
+ ),
+ x=0.5,
+ xanchor="center"
+ ) if title else None,
+ paper_bgcolor=DARK_THEME["paper_bgcolor"],
+ plot_bgcolor=DARK_THEME["plot_bgcolor"],
+ font=dict(
+ family=DARK_THEME["font_family"],
+ color=DARK_THEME["font_color"]
+ ),
+ xaxis=dict(
+ gridcolor=DARK_THEME["grid_color"],
+ color=DARK_THEME["axis_color"],
+ rangeslider_visible=False,
+ showgrid=True,
+ nticks=8,
+ tickformatstops=[
+ dict(dtickrange=[None, 3600000], value="%H:%M"),
+ dict(dtickrange=[3600000, 86400000], value="%H:%M\n%b %d"),
+ dict(dtickrange=[86400000, None], value="%b %d"),
+ ],
+ tickangle=0,
+ ),
+ yaxis=dict(
+ gridcolor=DARK_THEME["grid_color"],
+ color=DARK_THEME["axis_color"],
+ side="right",
+ showgrid=True
+ ),
+ showlegend=False,
+ width=width,
+ height=actual_height,
+ margin=dict(l=10, r=120, t=50, b=50)
+ )
+
+ # Update volume subplot axes if present
+ if volume_row:
+ fig.update_xaxes(
+ gridcolor=DARK_THEME["grid_color"],
+ showgrid=True,
+ row=2, col=1
+ )
+ fig.update_yaxes(
+ gridcolor=DARK_THEME["grid_color"],
+ color=DARK_THEME["axis_color"],
+ showgrid=True,
+ side="right",
+ row=2, col=1
+ )
+
+ # Convert to PNG bytes
+ img_bytes = io.BytesIO()
+ fig.write_image(img_bytes, format='png', scale=2)
+ img_bytes.seek(0)
+
+ return img_bytes
+
+ except ImportError as e:
+ logger.warning(f"Plotly not available for candlestick chart: {e}")
+ return None
+ except Exception as e:
+ logger.error(f"Error generating candlestick chart: {e}", exc_info=True)
+ return None
+
+
def generate_liquidity_chart(
bins: list,
active_bin_id: int = None,
@@ -93,16 +424,16 @@ def generate_liquidity_chart(
hovertemplate='Price: %{x:.6f}
Base Value: %{y:,.2f}'
))
- # Add current price line
+ # Add current price line (use unified theme)
if current_price:
fig.add_vline(
x=current_price,
line_dash="dash",
- line_color="#ef4444",
+ line_color=DARK_THEME["down_color"],
line_width=2,
annotation_text=f"Current: {current_price:.6f}",
annotation_position="top",
- annotation_font_color="#ef4444"
+ annotation_font_color=DARK_THEME["down_color"]
)
# Add lower price range line
@@ -110,11 +441,11 @@ def generate_liquidity_chart(
fig.add_vline(
x=lower_price,
line_dash="dot",
- line_color="#f59e0b",
+ line_color=DARK_THEME["current_price_color"],
line_width=2,
annotation_text=f"L: {lower_price:.6f}",
annotation_position="bottom left",
- annotation_font_color="#f59e0b"
+ annotation_font_color=DARK_THEME["current_price_color"]
)
# Add upper price range line
@@ -122,27 +453,33 @@ def generate_liquidity_chart(
fig.add_vline(
x=upper_price,
line_dash="dot",
- line_color="#f59e0b",
+ line_color=DARK_THEME["current_price_color"],
line_width=2,
annotation_text=f"U: {upper_price:.6f}",
annotation_position="bottom right",
- annotation_font_color="#f59e0b"
+ annotation_font_color=DARK_THEME["current_price_color"]
)
- # Update layout
+ # Update layout (use unified theme)
fig.update_layout(
title=dict(
- text=f"{pair_name} Liquidity Distribution",
- font=dict(size=16, color='white'),
+ text=f"{pair_name} Liquidity Distribution",
+ font=dict(
+ family=DARK_THEME["font_family"],
+ size=18,
+ color=DARK_THEME["font_color"]
+ ),
x=0.5
),
xaxis_title="Price",
yaxis_title="Liquidity (Quote Value)",
barmode='stack',
- template='plotly_dark',
- paper_bgcolor='#1a1a2e',
- plot_bgcolor='#16213e',
- font=dict(color='white'),
+ paper_bgcolor=DARK_THEME["paper_bgcolor"],
+ plot_bgcolor=DARK_THEME["plot_bgcolor"],
+ font=dict(
+ family=DARK_THEME["font_family"],
+ color=DARK_THEME["font_color"]
+ ),
legend=dict(
orientation="h",
yanchor="bottom",
@@ -155,17 +492,19 @@ def generate_liquidity_chart(
height=500
)
- # Update axes
+ # Update axes (use unified theme)
fig.update_xaxes(
showgrid=True,
gridwidth=1,
- gridcolor='rgba(255,255,255,0.1)',
+ gridcolor=DARK_THEME["grid_color"],
+ color=DARK_THEME["axis_color"],
tickformat='.5f'
)
fig.update_yaxes(
showgrid=True,
gridwidth=1,
- gridcolor='rgba(255,255,255,0.1)'
+ gridcolor=DARK_THEME["grid_color"],
+ color=DARK_THEME["axis_color"]
)
# Export to bytes
@@ -187,7 +526,7 @@ def generate_ohlcv_chart(
base_symbol: str = None,
quote_symbol: str = None
) -> Optional[io.BytesIO]:
- """Generate OHLCV candlestick chart using plotly
+ """Generate OHLCV candlestick chart using the unified candlestick function.
Args:
ohlcv_data: List of [timestamp, open, high, low, close, volume]
@@ -199,152 +538,22 @@ def generate_ohlcv_chart(
Returns:
BytesIO buffer with PNG image or None if failed
"""
- try:
- import plotly.graph_objects as go
- from plotly.subplots import make_subplots
-
- # Parse OHLCV data
- times = []
- opens = []
- highs = []
- lows = []
- closes = []
- volumes = []
-
- for candle in reversed(ohlcv_data): # Reverse for chronological order
- if len(candle) >= 5:
- ts, o, h, l, c = candle[:5]
- v = candle[5] if len(candle) > 5 else 0
-
- # Handle timestamp formats
- if isinstance(ts, (int, float)):
- times.append(datetime.fromtimestamp(ts))
- elif hasattr(ts, 'to_pydatetime'): # pandas Timestamp
- times.append(ts.to_pydatetime())
- elif isinstance(ts, datetime):
- times.append(ts)
- else:
- try:
- times.append(datetime.fromisoformat(str(ts).replace('Z', '+00:00')))
- except Exception:
- continue
-
- opens.append(float(o))
- highs.append(float(h))
- lows.append(float(l))
- closes.append(float(c))
- volumes.append(float(v) if v else 0)
-
- if not times:
- raise ValueError("No valid OHLCV data")
-
- # Create figure with subplots (candlestick + volume)
- fig = make_subplots(
- rows=2, cols=1,
- shared_xaxes=True,
- vertical_spacing=0.03,
- row_heights=[0.7, 0.3],
- )
-
- # Add candlestick chart
- fig.add_trace(
- go.Candlestick(
- x=times,
- open=opens,
- high=highs,
- low=lows,
- close=closes,
- name='Price',
- increasing_line_color='#00ff88',
- decreasing_line_color='#ff4444',
- increasing_fillcolor='#00ff88',
- decreasing_fillcolor='#ff4444',
- ),
- row=1, col=1
- )
-
- # Volume bar colors based on price direction
- volume_colors = ['#00ff88' if closes[i] >= opens[i] else '#ff4444' for i in range(len(times))]
-
- # Add volume bars
- fig.add_trace(
- go.Bar(
- x=times,
- y=volumes,
- name='Volume',
- marker_color=volume_colors,
- opacity=0.7,
- ),
- row=2, col=1
- )
-
- # Add latest price horizontal line
- if closes:
- latest_price = closes[-1]
- fig.add_hline(
- y=latest_price,
- line_dash="dash",
- line_color="#ffaa00",
- opacity=0.5,
- row=1, col=1,
- annotation_text=f"${latest_price:.6f}",
- annotation_position="right",
- annotation_font_color="#ffaa00",
- )
-
- # Build title
- if base_symbol and quote_symbol:
- title = f"{base_symbol}/{quote_symbol} - {timeframe}"
- else:
- title = f"{pair_name} - {timeframe}"
-
- # Update layout with dark theme
- fig.update_layout(
- title=dict(
- text=title,
- font=dict(color='white', size=16),
- x=0.5,
- ),
- paper_bgcolor='#1a1a2e',
- plot_bgcolor='#1a1a2e',
- font=dict(color='white'),
- xaxis_rangeslider_visible=False,
- showlegend=False,
- height=600,
- width=900,
- margin=dict(l=50, r=80, t=50, b=50),
- )
-
- # Update axes styling
- fig.update_xaxes(
- gridcolor='rgba(255,255,255,0.1)',
- showgrid=True,
- zeroline=False,
- )
- fig.update_yaxes(
- gridcolor='rgba(255,255,255,0.1)',
- showgrid=True,
- zeroline=False,
- side='right',
- )
-
- # Set y-axis titles
- fig.update_yaxes(title_text="Price (USD)", row=1, col=1)
- fig.update_yaxes(title_text="Volume", row=2, col=1)
-
- # Save to buffer as PNG
- buf = io.BytesIO()
- fig.write_image(buf, format='png', scale=2)
- buf.seek(0)
-
- return buf
-
- except ImportError as e:
- logger.warning(f"Plotly not available for OHLCV chart: {e}")
- return None
- except Exception as e:
- logger.error(f"Error generating OHLCV chart: {e}", exc_info=True)
- return None
+ # Build title
+ if base_symbol and quote_symbol:
+ title = f"{base_symbol}/{quote_symbol} - {timeframe}"
+ else:
+ title = f"{pair_name} - {timeframe}"
+
+ # Use the unified candlestick chart function
+ # GeckoTerminal returns newest first, so reverse_data=True
+ return generate_candlestick_chart(
+ candles=ohlcv_data,
+ title=title,
+ show_volume=True,
+ width=1100,
+ height=600,
+ reverse_data=True,
+ )
def generate_combined_chart(
@@ -464,7 +673,7 @@ def generate_combined_chart(
row_heights=[0.7, 0.3],
)
- # Add candlestick chart
+ # Add candlestick chart (use unified theme colors)
fig.add_trace(
go.Candlestick(
x=times,
@@ -473,16 +682,16 @@ def generate_combined_chart(
low=lows,
close=closes,
name='Price',
- increasing_line_color='#00ff88',
- decreasing_line_color='#ff4444',
- increasing_fillcolor='#00ff88',
- decreasing_fillcolor='#ff4444',
+ increasing_line_color=DARK_THEME["up_color"],
+ decreasing_line_color=DARK_THEME["down_color"],
+ increasing_fillcolor=DARK_THEME["up_color"],
+ decreasing_fillcolor=DARK_THEME["down_color"],
),
row=1, col=1
)
- # Add volume bars
- volume_colors = ['#00ff88' if closes[i] >= opens[i] else '#ff4444' for i in range(len(times))]
+ # Add volume bars (use unified theme colors)
+ volume_colors = [DARK_THEME["up_color"] if closes[i] >= opens[i] else DARK_THEME["down_color"] for i in range(len(times))]
fig.add_trace(
go.Bar(
x=times,
@@ -539,24 +748,24 @@ def generate_combined_chart(
row=1, col=2
)
- # Add current price line
+ # Add current price line (use unified theme colors)
price_to_mark = current_price or (closes[-1] if closes else None)
if price_to_mark:
fig.add_hline(
y=price_to_mark,
line_dash="dash",
- line_color="#ffaa00",
+ line_color=DARK_THEME["current_price_color"],
opacity=0.7,
row=1, col=1,
annotation_text=f"${price_to_mark:.6f}",
annotation_position="left",
- annotation_font_color="#ffaa00",
+ annotation_font_color=DARK_THEME["current_price_color"],
)
if has_liquidity:
fig.add_hline(
y=price_to_mark,
line_dash="dash",
- line_color="#ffaa00",
+ line_color=DARK_THEME["current_price_color"],
opacity=0.7,
row=1, col=2,
)
@@ -567,16 +776,23 @@ def generate_combined_chart(
else:
title = f"{pair_name} - {timeframe} + Liquidity"
- # Update layout
+ # Update layout (use unified theme)
fig.update_layout(
title=dict(
- text=title,
- font=dict(color='white', size=16),
+ text=f"{title}",
+ font=dict(
+ family=DARK_THEME["font_family"],
+ color=DARK_THEME["font_color"],
+ size=18
+ ),
x=0.5,
),
- paper_bgcolor='#1a1a2e',
- plot_bgcolor='#1a1a2e',
- font=dict(color='white'),
+ paper_bgcolor=DARK_THEME["paper_bgcolor"],
+ plot_bgcolor=DARK_THEME["plot_bgcolor"],
+ font=dict(
+ family=DARK_THEME["font_family"],
+ color=DARK_THEME["font_color"]
+ ),
xaxis_rangeslider_visible=False,
showlegend=has_liquidity, # Show legend when liquidity panel exists
legend=dict(
@@ -594,14 +810,16 @@ def generate_combined_chart(
bargap=0.1, # Gap between bars
)
- # Update axes styling
+ # Update axes styling (use unified theme)
fig.update_xaxes(
- gridcolor='rgba(255,255,255,0.1)',
+ gridcolor=DARK_THEME["grid_color"],
+ color=DARK_THEME["axis_color"],
showgrid=True,
zeroline=False,
)
fig.update_yaxes(
- gridcolor='rgba(255,255,255,0.1)',
+ gridcolor=DARK_THEME["grid_color"],
+ color=DARK_THEME["axis_color"],
showgrid=True,
zeroline=False,
)
@@ -760,31 +978,38 @@ def generate_aggregated_liquidity_chart(
hovertemplate='Price: %{x:.6f}
Base: %{y:,.2f}'
))
- # Add average price line
+ # Add average price line (use unified theme)
if avg_price and min_price <= avg_price <= max_price:
fig.add_vline(
x=avg_price,
line_dash="dash",
- line_color="#ef4444",
+ line_color=DARK_THEME["down_color"],
line_width=2,
annotation_text=f"Avg: {avg_price:.6f}",
annotation_position="top",
- annotation_font_color="#ef4444"
+ annotation_font_color=DARK_THEME["down_color"]
)
+ # Update layout (use unified theme)
fig.update_layout(
title=dict(
- text=f"{pair_name} Aggregated Liquidity ({len(valid_pools)} pools)",
- font=dict(size=16, color='white'),
+ text=f"{pair_name} Aggregated Liquidity ({len(valid_pools)} pools)",
+ font=dict(
+ family=DARK_THEME["font_family"],
+ size=18,
+ color=DARK_THEME["font_color"]
+ ),
x=0.5
),
xaxis_title="Price",
yaxis_title="Liquidity (Quote Value)",
barmode='stack',
- template='plotly_dark',
- paper_bgcolor='#1a1a2e',
- plot_bgcolor='#16213e',
- font=dict(color='white'),
+ paper_bgcolor=DARK_THEME["paper_bgcolor"],
+ plot_bgcolor=DARK_THEME["plot_bgcolor"],
+ font=dict(
+ family=DARK_THEME["font_family"],
+ color=DARK_THEME["font_color"]
+ ),
legend=dict(
orientation="h",
yanchor="bottom",
@@ -797,16 +1022,19 @@ def generate_aggregated_liquidity_chart(
height=550
)
+ # Update axes (use unified theme)
fig.update_xaxes(
showgrid=True,
gridwidth=1,
- gridcolor='rgba(255,255,255,0.1)',
+ gridcolor=DARK_THEME["grid_color"],
+ color=DARK_THEME["axis_color"],
tickformat='.5f'
)
fig.update_yaxes(
showgrid=True,
gridwidth=1,
- gridcolor='rgba(255,255,255,0.1)'
+ gridcolor=DARK_THEME["grid_color"],
+ color=DARK_THEME["axis_color"]
)
img_bytes = fig.to_image(format="png", scale=2)
diff --git a/handlers/portfolio.py b/handlers/portfolio.py
index 84bd457..a7182a3 100644
--- a/handlers/portfolio.py
+++ b/handlers/portfolio.py
@@ -21,6 +21,7 @@
get_portfolio_prefs,
set_portfolio_days,
PORTFOLIO_DAYS_OPTIONS,
+ get_all_enabled_networks,
)
from utils.portfolio_graphs import generate_portfolio_dashboard
from utils.trading_data import get_portfolio_overview
@@ -66,6 +67,40 @@ def _get_optimal_interval(days: int, max_points: int = 100) -> str:
return "1d"
+def _filter_balances_by_networks(balances: dict, enabled_networks: set) -> dict:
+ """
+ Filter portfolio balances to only include enabled networks.
+
+ The connector name in the portfolio state corresponds to the network
+ (e.g., 'solana-mainnet-beta', 'ethereum-mainnet', 'base').
+
+ Args:
+ balances: Portfolio state dict {account: {connector: [balances]}}
+ enabled_networks: Set of enabled network IDs, or None for no filtering
+
+ Returns:
+ Filtered balances dict with same structure
+ """
+ if enabled_networks is None:
+ return balances
+
+ if not balances:
+ return balances
+
+ filtered = {}
+ for account_name, account_data in balances.items():
+ filtered_account = {}
+ for connector_name, connector_balances in account_data.items():
+ # Check if this connector/network is enabled
+ connector_lower = connector_name.lower()
+ if connector_lower in enabled_networks:
+ filtered_account[connector_name] = connector_balances
+ if filtered_account:
+ filtered[account_name] = filtered_account
+
+ return filtered
+
+
def _parse_snapshot_tokens(state: dict) -> dict:
"""
Parse a state snapshot and return token holdings aggregated.
@@ -456,10 +491,15 @@ def _calculate_24h_changes(history_data: dict, current_balances: dict) -> dict:
return result
-async def _fetch_dashboard_data(client, days: int):
+async def _fetch_dashboard_data(client, days: int, refresh: bool = False):
"""
Fetch all data needed for the portfolio dashboard.
+ Args:
+ client: The API client
+ days: Number of days for history
+ refresh: If True, force refresh balances from exchanges (bypasses API cache)
+
Returns:
Tuple of (overview_data, history, token_distribution, accounts_distribution, pnl_history, graph_interval)
"""
@@ -472,7 +512,7 @@ async def _fetch_dashboard_data(client, days: int):
# Calculate optimal interval for the graph based on days
graph_interval = _get_optimal_interval(days)
- logger.info(f"Fetching portfolio data: days={days}, optimal_interval={graph_interval}, start_time={start_time}")
+ logger.info(f"Fetching portfolio data: days={days}, optimal_interval={graph_interval}, start_time={start_time}, refresh={refresh}")
# Fetch all data in parallel
overview_task = get_portfolio_overview(
@@ -481,7 +521,8 @@ async def _fetch_dashboard_data(client, days: int):
include_balances=True,
include_perp_positions=True,
include_lp_positions=True,
- include_active_orders=True
+ include_active_orders=True,
+ refresh=refresh
)
history_task = client.portfolio.get_history(
@@ -553,6 +594,7 @@ async def portfolio_command(update: Update, context: ContextTypes.DEFAULT_TYPE)
# Get the appropriate message object for replies
message = update.message or (update.callback_query.message if update.callback_query else None)
+ chat_id = update.effective_chat.id
if not message:
logger.error("No message object available for portfolio_command")
return
@@ -570,8 +612,8 @@ async def portfolio_command(update: Update, context: ContextTypes.DEFAULT_TYPE)
await message.reply_text(error_message, parse_mode="MarkdownV2")
return
- # Always use the default server from server_manager
- default_server = server_manager.get_default_server()
+ # Use per-chat default server, falling back to global default
+ default_server = server_manager.get_default_server_for_chat(chat_id)
if default_server and default_server in enabled_servers:
server_name = default_server
else:
@@ -597,10 +639,13 @@ async def portfolio_command(update: Update, context: ContextTypes.DEFAULT_TYPE)
pnl_start_time = _calculate_start_time(30)
graph_interval = _get_optimal_interval(days)
+ # Check if this is a refresh request (from callback)
+ refresh = context.user_data.pop("_portfolio_refresh", False)
+
# ========================================
# START ALL FETCHES IN PARALLEL
# ========================================
- balances_task = asyncio.create_task(client.portfolio.get_state())
+ balances_task = asyncio.create_task(client.portfolio.get_state(refresh=refresh))
perp_task = asyncio.create_task(get_perpetual_positions(client))
lp_task = asyncio.create_task(get_lp_positions(client))
orders_task = asyncio.create_task(get_active_orders(client))
@@ -659,6 +704,11 @@ async def update_ui(loading_text: str = None):
# ========================================
try:
balances = await balances_task
+ # Filter balances by enabled networks from wallet preferences
+ enabled_networks = get_all_enabled_networks(context.user_data)
+ if enabled_networks:
+ logger.info(f"Filtering portfolio by enabled networks: {enabled_networks}")
+ balances = _filter_balances_by_networks(balances, enabled_networks)
await update_ui("Loading positions & 24h data...")
except Exception as e:
logger.error(f"Failed to fetch balances: {e}")
@@ -732,8 +782,9 @@ async def update_ui(loading_text: str = None):
accounts_distribution_data=accounts_distribution
)
- # Create settings button
+ # Create buttons row with Refresh and Settings
keyboard = [[
+ InlineKeyboardButton("🔄 Refresh", callback_data="portfolio:refresh"),
InlineKeyboardButton(f"⚙️ Settings ({days}d)", callback_data="portfolio:settings")
]]
reply_markup = InlineKeyboardMarkup(keyboard)
@@ -778,7 +829,9 @@ async def portfolio_callback_handler(update: Update, context: ContextTypes.DEFAU
logger.info(f"Portfolio action: {action}")
- if action == "settings":
+ if action == "refresh":
+ await handle_portfolio_refresh(update, context)
+ elif action == "settings":
await show_portfolio_settings(update, context)
elif action.startswith("set_days:"):
days = int(action.split(":")[1])
@@ -805,8 +858,24 @@ async def portfolio_callback_handler(update: Update, context: ContextTypes.DEFAU
logger.error(f"Failed to send error message: {e2}")
-async def refresh_portfolio_dashboard(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Refresh both the text message and photo with new settings"""
+async def handle_portfolio_refresh(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle refresh button - force refresh balances from exchanges"""
+ query = update.callback_query
+ await query.answer("Refreshing from exchanges...")
+
+ # Set flag to force API refresh
+ context.user_data["_portfolio_refresh"] = True
+
+ # Refresh the dashboard with fresh data
+ await refresh_portfolio_dashboard(update, context, refresh=True)
+
+
+async def refresh_portfolio_dashboard(update: Update, context: ContextTypes.DEFAULT_TYPE, refresh: bool = False) -> None:
+ """Refresh both the text message and photo with new settings
+
+ Args:
+ refresh: If True, force refresh balances from exchanges (bypasses API cache)
+ """
query = update.callback_query
bot = query.get_bot()
@@ -822,14 +891,14 @@ async def refresh_portfolio_dashboard(update: Update, context: ContextTypes.DEFA
from servers import server_manager
from utils.trading_data import get_tokens_for_networks
- # Always use the default server from server_manager
+ # Use per-chat default server from server_manager
servers = server_manager.list_servers()
enabled_servers = [name for name, cfg in servers.items() if cfg.get("enabled", True)]
if not enabled_servers:
return
- default_server = server_manager.get_default_server()
+ default_server = server_manager.get_default_server_for_chat(chat_id)
if default_server and default_server in enabled_servers:
server_name = default_server
else:
@@ -854,10 +923,17 @@ async def refresh_portfolio_dashboard(update: Update, context: ContextTypes.DEFA
days = config.get("days", 3)
# Fetch all data (interval is calculated based on days)
+ # Pass refresh=True to force API to fetch fresh data from exchanges
overview_data, history, token_distribution, accounts_distribution, pnl_history, graph_interval = await _fetch_dashboard_data(
- client, days
+ client, days, refresh=refresh
)
+ # Filter balances by enabled networks from wallet preferences
+ enabled_networks = get_all_enabled_networks(context.user_data)
+ if enabled_networks and overview_data and overview_data.get('balances'):
+ logger.info(f"Filtering portfolio refresh by enabled networks: {enabled_networks}")
+ overview_data['balances'] = _filter_balances_by_networks(overview_data['balances'], enabled_networks)
+
# Calculate current portfolio value for PNL
current_value = 0.0
if overview_data and overview_data.get('balances'):
@@ -914,8 +990,9 @@ async def refresh_portfolio_dashboard(update: Update, context: ContextTypes.DEFA
accounts_distribution_data=accounts_distribution
)
- # Create settings button
+ # Create buttons row with Refresh and Settings
keyboard = [[
+ InlineKeyboardButton("🔄 Refresh", callback_data="portfolio:refresh"),
InlineKeyboardButton(f"⚙️ Settings ({days}d)", callback_data="portfolio:settings")
]]
reply_markup = InlineKeyboardMarkup(keyboard)
diff --git a/main.py b/main.py
index 32e47b3..605f69b 100644
--- a/main.py
+++ b/main.py
@@ -1,6 +1,7 @@
import logging
import importlib
import sys
+import os
import asyncio
from pathlib import Path
@@ -42,7 +43,11 @@ def _get_start_menu_keyboard() -> InlineKeyboardMarkup:
InlineKeyboardButton("💧 LP", callback_data="start:lp"),
],
[
- InlineKeyboardButton("⚙️ Config", callback_data="start:config"),
+ InlineKeyboardButton("🔌 Servers", callback_data="start:config_servers"),
+ InlineKeyboardButton("🔑 Keys", callback_data="start:config_keys"),
+ InlineKeyboardButton("🌐 Gateway", callback_data="start:config_gateway"),
+ ],
+ [
InlineKeyboardButton("❓ Help", callback_data="start:help"),
],
]
@@ -273,7 +278,7 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
🆔 *Your Chat Info*:
📱 Chat ID: `{chat_id}`
👤 User ID: `{user_id}`
-🏷️ Username: @{username}
+🏷️ Username: `@{username}`
Select a command below to get started:
"""
@@ -302,8 +307,23 @@ async def start_callback_handler(update: Update, context: ContextTypes.DEFAULT_T
await swap_command(update, context)
elif action == "lp":
await lp_command(update, context)
- elif action == "config":
- await config_command(update, context)
+ elif action == "config_servers":
+ from handlers.config.servers import show_api_servers
+ from handlers import clear_all_input_states
+ clear_all_input_states(context)
+ await show_api_servers(query, context)
+ elif action == "config_keys":
+ from handlers.config.api_keys import show_api_keys
+ from handlers import clear_all_input_states
+ clear_all_input_states(context)
+ await show_api_keys(query, context)
+ elif action == "config_gateway":
+ from handlers.config.gateway import show_gateway_menu
+ from handlers import clear_all_input_states
+ clear_all_input_states(context)
+ context.user_data.pop("dex_state", None)
+ context.user_data.pop("cex_state", None)
+ await show_gateway_menu(query, context)
elif action == "help":
await query.edit_message_text(
HELP_TEXTS["main"],
@@ -327,7 +347,7 @@ async def start_callback_handler(update: Update, context: ContextTypes.DEFAULT_T
🆔 *Your Chat Info*:
📱 Chat ID: `{chat_id}`
👤 User ID: `{user_id}`
-🏷️ Username: @{username}
+🏷️ Username: `@{username}`
Select a command below to get started:
"""
@@ -463,12 +483,28 @@ async def watch_and_reload(application: Application) -> None:
except Exception as e:
logger.error(f"❌ Auto-reload failed: {e}", exc_info=True)
+def get_persistence() -> PicklePersistence:
+ """
+ Build a persistence object that works both locally and in Docker.
+ - Uses an env var override if provided.
+ - Defaults to /data/condor_bot_data.pickle.
+ - Ensures the parent directory exists, but does NOT create the file.
+ """
+ base_dir = Path(__file__).parent
+ default_path = base_dir / "data" / "condor_bot_data.pickle"
+
+ persistence_path = Path(os.getenv("CONDOR_PERSISTENCE_FILE", default_path))
+
+ # Make sure the directory exists; the file will be created by PTB
+ persistence_path.parent.mkdir(parents=True, exist_ok=True)
+
+ return PicklePersistence(filepath=persistence_path)
def main() -> None:
"""Run the bot."""
# Setup persistence to save user data, chat data, and bot data
# This will save trading context, last used parameters, etc.
- persistence = PicklePersistence(filepath="condor_bot_data.pickle")
+ persistence = get_persistence()
# Create the Application with persistence enabled
application = (
diff --git a/requirements.txt b/requirements.txt
index 652f544..2f98647 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
python-telegram-bot[job-queue]
-hummingbot-api-client==1.2.3
+hummingbot-api-client==1.2.5
python-dotenv
pytest
pre-commit
diff --git a/servers.py b/servers.py
index cd79224..6dc854d 100644
--- a/servers.py
+++ b/servers.py
@@ -24,6 +24,7 @@ def __init__(self, config_path: str = "servers.yml"):
self.servers: Dict[str, dict] = {}
self.clients: Dict[str, HummingbotAPIClient] = {}
self.default_server: Optional[str] = None
+ self.per_chat_servers: Dict[int, str] = {} # chat_id -> server_name
self._load_config()
def _load_config(self):
@@ -40,6 +41,14 @@ def _load_config(self):
self.servers = config.get('servers', {})
self.default_server = config.get('default_server', None)
+ # Load per-chat server defaults
+ per_chat_raw = config.get('per_chat_defaults', {})
+ self.per_chat_servers = {
+ int(chat_id): server_name
+ for chat_id, server_name in per_chat_raw.items()
+ if server_name in self.servers
+ }
+
# Validate default server exists
if self.default_server and self.default_server not in self.servers:
logger.warning(f"Default server '{self.default_server}' not found in servers list")
@@ -48,10 +57,13 @@ def _load_config(self):
logger.info(f"Loaded {len(self.servers)} servers from {self.config_path}")
if self.default_server:
logger.info(f"Default server: {self.default_server}")
+ if self.per_chat_servers:
+ logger.info(f"Loaded {len(self.per_chat_servers)} per-chat server defaults")
except Exception as e:
logger.error(f"Failed to load config: {e}")
self.servers = {}
self.default_server = None
+ self.per_chat_servers = {}
def _save_config(self):
"""Save servers configuration to YAML file"""
@@ -59,6 +71,8 @@ def _save_config(self):
config = {'servers': self.servers}
if self.default_server:
config['default_server'] = self.default_server
+ if self.per_chat_servers:
+ config['per_chat_defaults'] = self.per_chat_servers
with open(self.config_path, 'w') as f:
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
logger.info(f"Saved configuration to {self.config_path}")
@@ -147,6 +161,54 @@ def get_default_server(self) -> Optional[str]:
"""Get the default server name"""
return self.default_server
+ def get_default_server_for_chat(self, chat_id: int) -> Optional[str]:
+ """Get the default server for a specific chat, falling back to global default"""
+ server = self.per_chat_servers.get(chat_id)
+ if server and server in self.servers:
+ return server
+ # Fallback to global default server
+ if self.default_server and self.default_server in self.servers:
+ return self.default_server
+ # Last resort: first available server
+ if self.servers:
+ return list(self.servers.keys())[0]
+ return None
+
+ def set_default_server_for_chat(self, chat_id: int, server_name: str) -> bool:
+ """Set the default server for a specific chat"""
+ if server_name not in self.servers:
+ logger.error(f"Server '{server_name}' not found")
+ return False
+
+ self.per_chat_servers[chat_id] = server_name
+ self._save_config()
+ logger.info(f"Set default server for chat {chat_id} to '{server_name}'")
+ return True
+
+ def clear_default_server_for_chat(self, chat_id: int) -> bool:
+ """Clear the per-chat default server, reverting to global default"""
+ if chat_id in self.per_chat_servers:
+ del self.per_chat_servers[chat_id]
+ self._save_config()
+ logger.info(f"Cleared default server for chat {chat_id}")
+ return True
+ return False
+
+ def get_chat_server_info(self, chat_id: int) -> dict:
+ """Get server info for a chat including whether it's using per-chat or global default"""
+ per_chat = self.per_chat_servers.get(chat_id)
+ if per_chat and per_chat in self.servers:
+ return {
+ "server": per_chat,
+ "is_per_chat": True,
+ "global_default": self.default_server
+ }
+ return {
+ "server": self.default_server,
+ "is_per_chat": False,
+ "global_default": self.default_server
+ }
+
async def check_server_status(self, name: str) -> dict:
"""
Check if a server is online and responding using protected endpoint
@@ -162,11 +224,12 @@ async def check_server_status(self, name: str) -> dict:
# Create a temporary client for testing (don't cache it)
# Important: Do NOT use cached clients to ensure we test current credentials
+ # Use 3 second timeout for quick status checks
client = HummingbotAPIClient(
base_url=base_url,
username=server['username'],
password=server['password'],
- timeout=ClientTimeout(10) # Shorter timeout for status check
+ timeout=ClientTimeout(total=3, connect=2) # Quick timeout for status check
)
try:
@@ -180,15 +243,18 @@ async def check_server_status(self, name: str) -> dict:
error_msg = str(e)
logger.warning(f"Status check failed for '{name}': {error_msg}")
- # Categorize the error
+ # Categorize the error with clearer messages
if "401" in error_msg or "Incorrect username or password" in error_msg:
return {"status": "auth_error", "message": "Invalid credentials"}
- elif "Connection" in error_msg or "Cannot connect" in error_msg:
+ elif "timeout" in error_msg.lower() or "TimeoutError" in error_msg:
+ return {"status": "offline", "message": "Connection timeout - server unreachable"}
+ elif "Connection" in error_msg or "Cannot connect" in error_msg or "ConnectionRefused" in error_msg:
return {"status": "offline", "message": "Cannot reach server"}
- elif "timeout" in error_msg.lower():
- return {"status": "offline", "message": "Connection timeout"}
+ elif "ClientConnectorError" in error_msg or "getaddrinfo" in error_msg:
+ return {"status": "offline", "message": "Server unreachable or invalid host"}
else:
- return {"status": "error", "message": f"Error: {error_msg[:50]}"}
+ # Show first 80 chars of error for debugging
+ return {"status": "error", "message": f"Error: {error_msg[:80]}"}
finally:
# Always close the client
try:
@@ -207,6 +273,18 @@ async def get_default_client(self) -> HummingbotAPIClient:
return await self.get_client(self.default_server)
+ async def get_client_for_chat(self, chat_id: int) -> HummingbotAPIClient:
+ """Get the API client for a specific chat's default server"""
+ server_name = self.get_default_server_for_chat(chat_id)
+ if not server_name:
+ # Fallback to first available server
+ if not self.servers:
+ raise ValueError("No servers configured")
+ server_name = list(self.servers.keys())[0]
+ logger.info(f"No default server for chat {chat_id}, using '{server_name}'")
+
+ return await self.get_client(server_name)
+
async def get_client(self, name: Optional[str] = None) -> HummingbotAPIClient:
"""Get or create API client for a server. If name is None, uses default server."""
if name is None:
@@ -221,13 +299,14 @@ async def get_client(self, name: Optional[str] = None) -> HummingbotAPIClient:
if name in self.clients:
return self.clients[name]
- # Create new client
+ # Create new client with longer timeout to handle slow operations
+ # (credential verification can take time as it connects to external exchanges)
base_url = f"http://{server['host']}:{server['port']}"
client = HummingbotAPIClient(
base_url=base_url,
username=server['username'],
password=server['password'],
- timeout=ClientTimeout(30)
+ timeout=ClientTimeout(total=60, connect=10)
)
try:
@@ -279,10 +358,11 @@ async def reload_config(self):
server_manager = ServerManager()
-async def get_client():
- """Get the API client for the default server.
+async def get_client(chat_id: int = None):
+ """Get the API client for the appropriate server.
- Convenience function that wraps server_manager.get_default_client().
+ Args:
+ chat_id: Optional chat ID to get per-chat server. If None, uses 'local' as fallback.
Returns:
HummingbotAPIClient instance
@@ -290,6 +370,8 @@ async def get_client():
Raises:
ValueError: If no servers are configured
"""
+ if chat_id is not None:
+ return await server_manager.get_client_for_chat(chat_id)
return await server_manager.get_default_client()
diff --git a/servers.yml b/servers.yml
index f1f1a52..c0785b2 100644
--- a/servers.yml
+++ b/servers.yml
@@ -1,12 +1,7 @@
servers:
-# remote:
-# host: 212.1.12.23
-# port: 8000
-# username: admin
-# password: admin
local:
host: localhost
port: 8000
username: admin
password: admin
-default_server: remote
+default_server: local
diff --git a/setup-environment.sh b/setup-environment.sh
index d9984a4..9772b10 100644
--- a/setup-environment.sh
+++ b/setup-environment.sh
@@ -12,13 +12,14 @@ read -p "Enter your Telegram Bot Token: " telegram_token
echo ""
echo "Enter the User IDs that are allowed to talk with the bot."
echo "Separate multiple User IDs with a comma (e.g., 12345,67890,23456)."
+echo "(Tip: Run /start in the bot to see your User ID)"
read -p "User IDs: " user_ids
-# Prompt for OpenAI API Key (optional)
+# Prompt for Pydantic Gateway Key (optional)
echo ""
-echo "Enter your OpenAI API Key (optional, for AI features)."
+echo "Enter your Pydantic Gateway Key (optional, for AI features)."
echo "Press Enter to skip if not using AI features."
-read -p "OpenAI API Key: " openai_key
+read -p "Pydantic Gateway Key: " pydantic_key
# Remove spaces from user IDs
user_ids=$(echo $user_ids | tr -d '[:space:]')
@@ -26,8 +27,8 @@ user_ids=$(echo $user_ids | tr -d '[:space:]')
# Create or update .env file
echo "TELEGRAM_TOKEN=$telegram_token" > .env
echo "AUTHORIZED_USERS=$user_ids" >> .env
-if [ -n "$openai_key" ]; then
- echo "OPENAI_API_KEY=$openai_key" >> .env
+if [ -n "$pydantic_key" ]; then
+ echo "PYDANTIC_GATEWAY_KEY=$pydantic_key" >> .env
fi
echo ""
@@ -35,16 +36,20 @@ echo ".env file created successfully!"
echo ""
echo "Installing Chrome for Plotly image generation..."
-plotly_get_chrome || kaleido_get_chrome || python -c "import kaleido; kaleido.get_chrome_sync()"
+plotly_get_chrome || kaleido_get_chrome || python -c "import kaleido; kaleido.get_chrome_sync()" 2>/dev/null || echo "Chrome installation skipped (not required for basic usage)"
echo ""
+echo "Ensuring data directory exists for persistence..."
+mkdir -p data
+
echo "==================================="
echo " How to Run Condor"
echo "==================================="
echo ""
echo "Option 1: Docker (Recommended)"
-echo " docker-compose up -d"
+echo " docker compose up -d"
echo ""
echo "Option 2: Local Python"
+echo " make install"
echo " conda activate condor"
echo " python main.py"
echo ""
diff --git a/utils/auth.py b/utils/auth.py
index 59fb706..1979aff 100644
--- a/utils/auth.py
+++ b/utils/auth.py
@@ -1,10 +1,13 @@
+import logging
from functools import wraps
-from telegram import Update
+from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ContextTypes
from utils.config import AUTHORIZED_USERS
+logger = logging.getLogger(__name__)
+
def restricted(func):
@wraps(func)
@@ -19,3 +22,132 @@ async def wrapped(
return await func(update, context, *args, **kwargs)
return wrapped
+
+
+async def _send_service_unavailable_message(
+ update: Update,
+ title: str,
+ status_line: str,
+ instruction: str,
+ close_callback: str = "dex:close"
+) -> None:
+ """Send a standardized service unavailable message."""
+ message = f"⚠️ *{title}*\n\n"
+ message += f"{status_line}\n\n"
+ message += instruction
+
+ keyboard = [[InlineKeyboardButton("✕ Close", callback_data=close_callback)]]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ if update.message:
+ await update.message.reply_text(
+ message,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+ elif update.callback_query:
+ await update.callback_query.message.edit_text(
+ message,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup
+ )
+
+
+def gateway_required(func):
+ """
+ Decorator that checks if the Gateway is running on the default server.
+ If not running, displays an error message and prevents the handler from executing.
+
+ Usage:
+ @gateway_required
+ async def handle_liquidity(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ ...
+ """
+ @wraps(func)
+ async def wrapped(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs
+ ):
+ try:
+ from handlers.config.server_context import get_gateway_status_info, get_server_context_header
+
+ # Check server status first
+ server_header, server_online = await get_server_context_header()
+
+ if not server_online:
+ await _send_service_unavailable_message(
+ update,
+ title="Server Offline",
+ status_line="🔴 The API server is not reachable\\.",
+ instruction="Check your server configuration in /config \\> API Servers\\."
+ )
+ return
+
+ # Check gateway status
+ _, gateway_running = await get_gateway_status_info()
+
+ if not gateway_running:
+ await _send_service_unavailable_message(
+ update,
+ title="Gateway Not Running",
+ status_line="🔴 The Gateway is not deployed or not running on this server\\.",
+ instruction="Deploy the Gateway in /config \\> Gateway to use this feature\\."
+ )
+ return
+
+ return await func(update, context, *args, **kwargs)
+
+ except Exception as e:
+ logger.error(f"Error checking gateway status: {e}", exc_info=True)
+ await _send_service_unavailable_message(
+ update,
+ title="Service Unavailable",
+ status_line="⚠️ Could not verify service status\\.",
+ instruction="Please try again or check /config for server status\\."
+ )
+ return
+
+ return wrapped
+
+
+def hummingbot_api_required(func):
+ """
+ Decorator that checks if the Hummingbot API server is online.
+ If offline, displays an error message and prevents the handler from executing.
+
+ Usage:
+ @hummingbot_api_required
+ async def handle_some_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ ...
+ """
+ @wraps(func)
+ async def wrapped(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs
+ ):
+ try:
+ from handlers.config.server_context import get_server_context_header
+
+ # Check server status
+ server_header, server_online = await get_server_context_header()
+
+ if not server_online:
+ await _send_service_unavailable_message(
+ update,
+ title="API Server Offline",
+ status_line="🔴 The Hummingbot API server is not reachable\\.",
+ instruction="Check your server configuration in /config \\> API Servers\\."
+ )
+ return
+
+ return await func(update, context, *args, **kwargs)
+
+ except Exception as e:
+ logger.error(f"Error checking API server status: {e}", exc_info=True)
+ await _send_service_unavailable_message(
+ update,
+ title="Service Unavailable",
+ status_line="⚠️ Could not verify service status\\.",
+ instruction="Please try again or check /config for server status\\."
+ )
+ return
+
+ return wrapped
diff --git a/utils/trading_data.py b/utils/trading_data.py
index 3583174..b93aec9 100644
--- a/utils/trading_data.py
+++ b/utils/trading_data.py
@@ -269,6 +269,7 @@ async def get_portfolio_overview(
include_perp_positions: bool = True,
include_lp_positions: bool = True,
include_active_orders: bool = True,
+ refresh: bool = False,
) -> Dict[str, Any]:
"""
Get a unified portfolio overview with all position types
@@ -280,6 +281,7 @@ async def get_portfolio_overview(
include_perp_positions: Include perpetual positions (default: True)
include_lp_positions: Include LP (CLMM) positions (default: True)
include_active_orders: Include active orders (default: True)
+ refresh: If True, force refresh balances from exchanges (bypasses API cache)
Returns:
Dictionary containing all portfolio data:
@@ -297,7 +299,7 @@ async def get_portfolio_overview(
tasks = {}
if include_balances:
- tasks['balances'] = client.portfolio.get_state()
+ tasks['balances'] = client.portfolio.get_state(refresh=refresh)
if include_perp_positions:
tasks['perp_positions'] = get_perpetual_positions(client, account_names)