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)