From 6d4350fea5be2304640763b19098497dd0865317 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Fri, 5 Dec 2025 16:19:28 -0300 Subject: [PATCH 01/51] (feat) bump hbot client version --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 652f544..98ccba7 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.4 python-dotenv pytest pre-commit From 6ddc34b555ee5b0867b27f96f0c6689efe8dcc08 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Fri, 5 Dec 2025 16:42:13 -0300 Subject: [PATCH 02/51] (feat) fix ui issues --- handlers/bots/__init__.py | 68 +++ handlers/bots/controller_handlers.py | 743 ++++++++++++++++++++++++-- handlers/bots/controllers/__init__.py | 3 + handlers/bots/menu.py | 143 ++++- 4 files changed, 900 insertions(+), 57 deletions(-) diff --git a/handlers/bots/__init__.py b/handlers/bots/__init__.py index b537953..604205e 100644 --- a/handlers/bots/__init__.py +++ b/handlers/bots/__init__.py @@ -45,6 +45,7 @@ show_configs_list, handle_configs_page, show_new_grid_strike_form, + show_new_pmm_mister_form, show_config_form, handle_set_field, handle_toggle_side, @@ -100,6 +101,19 @@ 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_advanced, + handle_pmm_adv_setting, + process_pmm_wizard_input, ) from ._shared import clear_bots_state, SIDE_LONG, SIDE_SHORT @@ -195,6 +209,9 @@ async def bots_callback_handler(update: Update, context: ContextTypes.DEFAULT_TY 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]) @@ -372,6 +389,54 @@ 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_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: @@ -483,6 +548,9 @@ 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) else: logger.debug(f"Unhandled bots state: {bots_state}") diff --git a/handlers/bots/controller_handlers.py b/handlers/bots/controller_handlers.py index 2134304..fe07284 100644 --- a/handlers/bots/controller_handlers.py +++ b/handlers/bots/controller_handlers.py @@ -186,11 +186,25 @@ async def show_controller_configs_menu(update: Update, context: ContextTypes.DEF reply_markup = InlineKeyboardMarkup(keyboard) - await query.message.edit_text( - "\n".join(lines), - parse_mode="MarkdownV2", - reply_markup=reply_markup - ) + text_content = "\n".join(lines) + + # Handle photo messages (e.g., coming back from prices step with chart) + if query.message.photo: + try: + await query.message.delete() + except Exception: + pass + await query.message.chat.send_message( + text_content, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) + else: + await query.message.edit_text( + text_content, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) except Exception as e: logger.error(f"Error loading controller configs: {e}", exc_info=True) @@ -199,11 +213,25 @@ async def show_controller_configs_menu(update: Update, context: ContextTypes.DEF [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 query.message.photo: + 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: @@ -509,18 +537,15 @@ async def handle_gs_wizard_amount(update: Update, context: ContextTypes.DEFAULT_ config["total_amount_quote"] = amount set_controller_config(context, config) - # 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", "") - # 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" - ) + # 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" + ) # Move to prices step - this will fetch OHLC and show chart context.user_data["gs_wizard_step"] = "prices" @@ -694,22 +719,50 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT 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 + # No chart - handle photo messages + if query.message.photo: + 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"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) - ) + error_msg = format_error_message(f"Error fetching market data: {str(e)}") + try: + if query.message.photo: + 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_gs_accept_prices(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -3572,3 +3625,629 @@ async def process_deploy_custom_name_input(update: Update, context: ContextTypes parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard) ) + + +# ============================================ +# PMM MISTER WIZARD +# ============================================ + +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 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 + + 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: {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 + + 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 + + try: + client = await get_bots_client() + cex_connectors = await get_available_cex_connectors(context.user_data, client) + + if not cex_connectors: + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:controller_configs")]] + 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 + + 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:controller_configs")]) + + 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) + ) + + except Exception as e: + logger.error(f"Error in PMM 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_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:controller_configs")]) + + 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) + context.user_data["pmm_wizard_step"] = "leverage" + await _show_pmm_wizard_leverage_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:controller_configs")], + ] + + 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("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:controller_configs")], + ] + + 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) + + context.user_data["bots_state"] = "pmm_wizard_input" + context.user_data["pmm_wizard_step"] = "spreads" + + keyboard = [ + [InlineKeyboardButton("Tight: 0.5%, 1%", callback_data="bots:pmm_spreads:0.005,0.01")], + [InlineKeyboardButton("Normal: 1%, 2%", callback_data="bots:pmm_spreads:0.01,0.02")], + [InlineKeyboardButton("Wide: 2%, 3%, 5%", callback_data="bots:pmm_spreads:0.02,0.03,0.05")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + ] + + 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\n" + r"*Step 5/7:* πŸ“Š Spreads" + "\n\n" + r"_Or type custom: `0\.01,0\.02`_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + +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) + + +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", "") + spreads = config.get("buy_spreads", "0.01,0.02") + + 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:controller_configs")], + ] + + await query.message.edit_text( + r"*πŸ“ˆ PMM Mister \- New Config*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n" + f"πŸ“Š Spreads: `{escape_markdown_v2(spreads)}`" + "\n\n" + r"*Step 6/7:* 🎯 Take Profit", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + +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 _show_pmm_wizard_review_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """PMM Wizard Step 7: Review""" + query = update.callback_query + config = get_controller_config(context) + + existing = context.user_data.get("controller_configs_list", []) + config["id"] = pmm_generate_id(config, existing) + set_controller_config(context, config) + + keyboard = [ + [InlineKeyboardButton("βœ… Save Config", callback_data="bots:pmm_save")], + [ + InlineKeyboardButton("✏️ Edit ID", callback_data="bots:pmm_edit_id"), + InlineKeyboardButton("βš™οΈ Advanced", callback_data="bots:pmm_edit_advanced"), + ], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + ] + + await query.message.edit_text( + r"*πŸ“ˆ PMM Mister \- Review*" + "\n\n" + f"*ID:* `{escape_markdown_v2(config.get('id', ''))}`" + "\n\n" + f"🏦 *Connector:* `{escape_markdown_v2(config.get('connector_name', ''))}`" + "\n" + f"πŸ”— *Pair:* `{escape_markdown_v2(config.get('trading_pair', ''))}`" + "\n" + f"⚑ *Leverage:* `{config.get('leverage', 20)}x`" + "\n" + f"πŸ’° *Allocation:* `{config.get('portfolio_allocation', 0.05)*100:.0f}%`" + "\n\n" + f"πŸ“Š *Spreads:* `{escape_markdown_v2(config.get('buy_spreads', ''))}`" + "\n" + f"🎯 *Take Profit:* `{config.get('take_profit', 0.0001)*100:.2f}%`" + "\n\n" + f"πŸ“ˆ *Base %:* `{config.get('min_base_pct', 0.1)*100:.0f}%` / " + f"`{config.get('target_base_pct', 0.2)*100:.0f}%` / " + f"`{config.get('max_base_pct', 0.4)*100:.0f}%`", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + +async def handle_pmm_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Save PMM config""" + query = update.callback_query + config = get_controller_config(context) + + 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 + + try: + client = await get_bots_client() + result = await client.controllers.add_controller_config(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( + f"*Error*\n\n{escape_markdown_v2(str(e)[:200])}", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + +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 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" + + 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) + ) + + +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("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 query.message.edit_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) + ) + + +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) + + context.user_data["bots_state"] = "pmm_wizard_input" + context.user_data["pmm_wizard_step"] = f"adv_{setting}" + + 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, "", "")) + + 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) + ) + + +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: + await update.message.delete() + except Exception: + pass + + if step == "trading_pair": + config["trading_pair"] = user_input.upper() + set_controller_config(context, config) + context.user_data["pmm_wizard_step"] = "leverage" + # Edit the wizard message + 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:controller_configs")], + ] + await context.bot.edit_message_text( + chat_id=chat_id, message_id=message_id, + text=r"*πŸ“ˆ PMM Mister \- New Config*" + "\n\n" + f"🏦 `{escape_markdown_v2(config.get('connector_name', ''))}` \\| πŸ”— `{escape_markdown_v2(config['trading_pair'])}`" + "\n\n" + r"*Step 3/7:* ⚑ Leverage", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + 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" + 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:controller_configs")], + ] + await context.bot.edit_message_text( + chat_id=chat_id, message_id=message_id, + text=r"*πŸ“ˆ PMM Mister \- New Config*" + "\n\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 == "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) + + +async def _pmm_show_review(context, chat_id, message_id, config): + """Helper to show review step""" + keyboard = [ + [InlineKeyboardButton("βœ… Save Config", callback_data="bots:pmm_save")], + [ + InlineKeyboardButton("✏️ Edit ID", callback_data="bots:pmm_edit_id"), + InlineKeyboardButton("βš™οΈ Advanced", callback_data="bots:pmm_edit_advanced"), + ], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + ] + await context.bot.edit_message_text( + chat_id=chat_id, message_id=message_id, + text=r"*πŸ“ˆ PMM Mister \- Review*" + "\n\n" + f"*ID:* `{escape_markdown_v2(config.get('id', ''))}`" + "\n\n" + f"🏦 *Connector:* `{escape_markdown_v2(config.get('connector_name', ''))}`" + "\n" + f"πŸ”— *Pair:* `{escape_markdown_v2(config.get('trading_pair', ''))}`" + "\n" + f"⚑ *Leverage:* `{config.get('leverage', 20)}x`" + "\n" + f"πŸ’° *Allocation:* `{config.get('portfolio_allocation', 0.05)*100:.0f}%`" + "\n\n" + f"πŸ“Š *Spreads:* `{escape_markdown_v2(config.get('buy_spreads', ''))}`" + "\n" + f"🎯 *Take Profit:* `{config.get('take_profit', 0.0001)*100:.2f}%`", + 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/menu.py b/handlers/bots/menu.py index 02009ba..d1c1835 100644 --- a/handlers/bots/menu.py +++ b/handlers/bots/menu.py @@ -45,9 +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("βž• PMM Mister", callback_data="bots:new_pmm_mister"), + ]) + + # Action buttons - deploy and configs + keyboard.append([ InlineKeyboardButton("πŸš€ Deploy", callback_data="bots:deploy_menu"), InlineKeyboardButton("πŸ“ Configs", callback_data="bots:controller_configs"), ]) @@ -286,11 +291,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 query.message.photo: + # 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 +320,25 @@ 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 query.message.photo: + 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 _shorten_controller_name(name: str, max_len: int = 28) -> str: @@ -406,6 +438,7 @@ async def show_controller_detail(update: Update, context: ContextTypes.DEFAULT_T ctrl_config = None is_grid_strike = False chart_bytes = None + message_replaced = False # Track if we've sent a new message (e.g., loading message) try: client = await get_bots_client() @@ -425,6 +458,22 @@ async def show_controller_detail(update: Update, context: ContextTypes.DEFAULT_T # For grid strike, generate chart if is_grid_strike: + # Show loading message immediately since chart generation takes time + loading_text = f"⏳ *Generating chart for* `{escape_markdown_v2(short_name)}`\\.\\.\\." + try: + if query.message.photo: + # Delete photo and send text message + try: + await query.message.delete() + except Exception: + pass + await query.message.chat.send_message(loading_text, parse_mode="MarkdownV2") + message_replaced = True + else: + await query.message.edit_text(loading_text, parse_mode="MarkdownV2") + except Exception: + pass + try: connector = ctrl_config.get("connector_name", "") pair = ctrl_config.get("trading_pair", "") @@ -531,11 +580,25 @@ async def show_controller_detail(update: Update, context: ContextTypes.DEFAULT_T "", ] + caption_lines - await query.message.edit_text( - "\n".join(full_lines), - parse_mode="MarkdownV2", - reply_markup=reply_markup - ) + text_content = "\n".join(full_lines) + + # If we replaced the original message (e.g., with loading message), send new message + if message_replaced or query.message.photo: + try: + await query.message.delete() + except Exception: + pass + await query.message.chat.send_message( + text_content, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) + else: + await query.message.edit_text( + text_content, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) async def handle_stop_controller(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -560,14 +623,30 @@ 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 query.message.photo: + 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""" @@ -711,11 +790,25 @@ async def show_controller_edit(update: Update, context: ContextTypes.DEFAULT_TYP reply_markup = InlineKeyboardMarkup(keyboard) - await query.message.edit_text( - "\n".join(lines), - parse_mode="MarkdownV2", - reply_markup=reply_markup - ) + text_content = "\n".join(lines) + + # Handle photo messages (from controller detail view with chart) + if query.message.photo: + try: + await query.message.delete() + except Exception: + pass + await query.message.chat.send_message( + text_content, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) + else: + await query.message.edit_text( + text_content, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) async def handle_controller_set_field(update: Update, context: ContextTypes.DEFAULT_TYPE, field_name: str) -> None: From 7a690a35af610bfd6b7b96911126135689dbd1df Mon Sep 17 00:00:00 2001 From: cardosofede Date: Fri, 5 Dec 2025 16:42:19 -0300 Subject: [PATCH 03/51] (feat) add pmm mister --- .../bots/controllers/pmm_mister/__init__.py | 101 +++++ handlers/bots/controllers/pmm_mister/chart.py | 256 ++++++++++++ .../bots/controllers/pmm_mister/config.py | 383 ++++++++++++++++++ 3 files changed, 740 insertions(+) create mode 100644 handlers/bots/controllers/pmm_mister/__init__.py create mode 100644 handlers/bots/controllers/pmm_mister/chart.py create mode 100644 handlers/bots/controllers/pmm_mister/config.py diff --git a/handlers/bots/controllers/pmm_mister/__init__.py b/handlers/bots/controllers/pmm_mister/__init__.py new file mode 100644 index 0000000..6073d69 --- /dev/null +++ b/handlers/bots/controllers/pmm_mister/__init__.py @@ -0,0 +1,101 @@ +""" +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 + + +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", +] diff --git a/handlers/bots/controllers/pmm_mister/chart.py b/handlers/bots/controllers/pmm_mister/chart.py new file mode 100644 index 0000000..06b1279 --- /dev/null +++ b/handlers/bots/controllers/pmm_mister/chart.py @@ -0,0 +1,256 @@ +""" +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 +- Base percentage target zone indicator +""" + +import io +from datetime import datetime +from typing import Any, Dict, List, Optional + +import plotly.graph_objects as go + +from .config import parse_spreads + + +# Dark theme (consistent with grid_strike) +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/buy + "down_color": "#ef4444", # Red for bearish/sell + "line_color": "#3b82f6", # Blue for lines + "target_color": "#f59e0b", # Orange for target +} + + +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) + - 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", []) + + if not data: + # Create empty chart with message + 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"] + ) + ) + else: + # Extract OHLCV data + timestamps = [] + 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: + timestamps.append(dt) + else: + timestamps.append(str(raw_ts)) + + opens.append(candle.get("open", 0)) + highs.append(candle.get("high", 0)) + lows.append(candle.get("low", 0)) + closes.append(candle.get("close", 0)) + + # Use current_price or last close + ref_price = current_price or (closes[-1] if closes else 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 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) + opacity = 0.8 - (i * 0.15) # Fade out for further levels + fig.add_hline( + y=buy_price, + line_dash="dash", + line_color=DARK_THEME["up_color"], + line_width=2, + opacity=max(0.3, opacity), + annotation_text=f"Buy L{i+1}: {buy_price:,.4f} (-{spread*100:.1f}%)", + annotation_position="left", + annotation_font=dict(color=DARK_THEME["up_color"], size=9) + ) + + # 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 = 0.8 - (i * 0.15) + fig.add_hline( + y=sell_price, + line_dash="dash", + line_color=DARK_THEME["down_color"], + line_width=2, + opacity=max(0.3, opacity), + annotation_text=f"Sell L{i+1}: {sell_price:,.4f} (+{spread*100:.1f}%)", + annotation_position="right", + annotation_font=dict(color=DARK_THEME["down_color"], size=9) + ) + + # Add take profit indicator as a shaded zone + if ref_price and take_profit: + tp_up = ref_price * (1 + take_profit) + tp_down = ref_price * (1 - take_profit) + fig.add_hrect( + y0=tp_down, + y1=tp_up, + fillcolor="rgba(245, 158, 11, 0.1)", + line_width=0, + annotation_text=f"TP Zone ({take_profit*100:.2f}%)", + annotation_position="top right", + annotation_font=dict(color=DARK_THEME["target_color"], size=9) + ) + + # Current price line + if current_price: + fig.add_hline( + y=current_price, + line_dash="solid", + line_color=DARK_THEME["target_color"], + line_width=2, + annotation_text=f"Current: {current_price:,.4f}", + annotation_position="left", + annotation_font=dict(color=DARK_THEME["target_color"], size=10) + ) + + # Build title + title_text = f"{trading_pair} - PMM Mister" + + # 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, + tickformat="%b %d\n%H:%M", + tickangle=0, + ), + 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=140, t=50, b=50) + ) + + # Convert to PNG bytes + img_bytes = io.BytesIO() + fig.write_image(img_bytes, format='png', scale=2) + img_bytes.seek(0) + + return img_bytes + + +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. + """ + 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..1402826 --- /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.01,0.02", + "sell_spreads": "0.01,0.02", + "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.01,0.02)", + default="0.01,0.02" + ), + "sell_spreads": ControllerField( + name="sell_spreads", + label="Sell Spreads", + type="str", + required=True, + hint="Comma-separated spreads (e.g. 0.01,0.02)", + default="0.01,0.02" + ), + "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}" From 7bf4dd8c1ecd3d3a21f28434f24c2ee75a750b37 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Mon, 8 Dec 2025 13:18:41 -0300 Subject: [PATCH 04/51] (feat) fix token cache --- handlers/dex/liquidity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/handlers/dex/liquidity.py b/handlers/dex/liquidity.py index d56d419..580d856 100644 --- a/handlers/dex/liquidity.py +++ b/handlers/dex/liquidity.py @@ -512,6 +512,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: From 7447f543e0a178f4f66f3d6e0ad879e6ff0e0e52 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 9 Dec 2025 21:49:07 -0300 Subject: [PATCH 05/51] (feat) improve pmm mister controller --- handlers/bots/__init__.py | 13 + handlers/bots/controller_handlers.py | 495 ++++++++++++++---- .../bots/controllers/pmm_mister/config.py | 12 +- 3 files changed, 422 insertions(+), 98 deletions(-) diff --git a/handlers/bots/__init__.py b/handlers/bots/__init__.py index 604205e..42090f0 100644 --- a/handlers/bots/__init__.py +++ b/handlers/bots/__init__.py @@ -111,6 +111,8 @@ 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, @@ -429,6 +431,17 @@ async def bots_callback_handler(update: Update, context: ContextTypes.DEFAULT_TY 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) diff --git a/handlers/bots/controller_handlers.py b/handlers/bots/controller_handlers.py index fe07284..cd3dd91 100644 --- a/handlers/bots/controller_handlers.py +++ b/handlers/bots/controller_handlers.py @@ -285,7 +285,7 @@ async def _show_wizard_connector_step(update: Update, context: ContextTypes.DEFA cex_connectors = await get_available_cex_connectors(context.user_data, client) if not cex_connectors: - keyboard = [[InlineKeyboardButton("Back", callback_data="bots:controller_configs")]] + 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" @@ -306,7 +306,7 @@ async def _show_wizard_connector_step(update: Update, context: ContextTypes.DEFA if row: keyboard.append(row) - keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) await query.message.edit_text( r"*πŸ“ˆ Grid Strike \- New Config*" + "\n\n" @@ -318,7 +318,7 @@ async def _show_wizard_connector_step(update: Update, context: ContextTypes.DEFA except Exception as e: logger.error(f"Error in connector step: {e}", exc_info=True) - keyboard = [[InlineKeyboardButton("Back", callback_data="bots:controller_configs")]] + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] await query.message.edit_text( format_error_message(f"Error: {str(e)}"), parse_mode="MarkdownV2", @@ -387,7 +387,7 @@ async def _show_wizard_pair_step(update: Update, context: ContextTypes.DEFAULT_T if row: keyboard.append(row) - keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) recent_hint = "" if recent_pairs: @@ -416,7 +416,7 @@ async def _show_wizard_side_step(update: Update, context: ContextTypes.DEFAULT_T InlineKeyboardButton("πŸ“ˆ LONG", callback_data="bots:gs_side:long"), InlineKeyboardButton("πŸ“‰ SHORT", callback_data="bots:gs_side:short"), ], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], ] await query.message.edit_text( @@ -438,9 +438,18 @@ async def handle_gs_wizard_side(update: Update, context: ContextTypes.DEFAULT_TY config["side"] = SIDE_LONG if side_str == "long" else SIDE_SHORT set_controller_config(context, config) - # Move to leverage step - context.user_data["gs_wizard_step"] = "leverage" - await _show_wizard_leverage_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 _show_wizard_leverage_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -463,7 +472,7 @@ async def _show_wizard_leverage_step(update: Update, context: ContextTypes.DEFAU InlineKeyboardButton("50x", callback_data="bots:gs_leverage:50"), InlineKeyboardButton("75x", callback_data="bots:gs_leverage:75"), ], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], ] await query.message.edit_text( @@ -514,7 +523,7 @@ async def _show_wizard_amount_step(update: Update, context: ContextTypes.DEFAULT InlineKeyboardButton("πŸ’° 2000", callback_data="bots:gs_amount:2000"), InlineKeyboardButton("πŸ’° 5000", callback_data="bots:gs_amount:5000"), ], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], ] await query.message.edit_text( @@ -612,7 +621,7 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT context.user_data["gs_candles_interval"] = interval if not current_price: - keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:controller_configs")]] + keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]] try: await query.message.edit_text( r"*❌ Error*" + "\n\n" @@ -671,7 +680,7 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT [ InlineKeyboardButton("βœ… Accept Prices", callback_data="bots:gs_accept_prices"), ], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], ] # Format example with current values @@ -742,7 +751,7 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT except Exception as e: logger.error(f"Error in prices step: {e}", exc_info=True) - keyboard = [[InlineKeyboardButton("Back", callback_data="bots:controller_configs")]] + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] error_msg = format_error_message(f"Error fetching market data: {str(e)}") try: if query.message.photo: @@ -800,7 +809,7 @@ async def handle_gs_accept_prices(update: Update, context: ContextTypes.DEFAULT_ # 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")], + [InlineKeyboardButton("Cancel", callback_data="bots:main_menu")], ] try: await query.message.delete() @@ -861,7 +870,7 @@ async def _show_wizard_take_profit_step(update: Update, context: ContextTypes.DE 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")], ] message_text = ( @@ -977,7 +986,7 @@ async def _show_wizard_review_step(update: Update, context: ContextTypes.DEFAULT InlineKeyboardButton("βœ… Save Config", callback_data="bots:gs_save"), ], [ - InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), ], ] @@ -1049,7 +1058,7 @@ async def _update_wizard_message_for_review(update: Update, context: ContextType InlineKeyboardButton("βœ… Save Config", callback_data="bots:gs_save"), ], [ - InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), ], ] @@ -1637,7 +1646,7 @@ async def _update_wizard_message_for_side(update: Update, context: ContextTypes. InlineKeyboardButton("πŸ“ˆ LONG", callback_data="bots:gs_side:long"), InlineKeyboardButton("πŸ“‰ SHORT", callback_data="bots:gs_side:short"), ], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], ] try: @@ -1724,7 +1733,7 @@ async def _update_wizard_message_for_prices_after_edit(update: Update, context: [ InlineKeyboardButton("βœ… Accept Prices", callback_data="bots:gs_accept_prices"), ], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], ] # Format example with current values @@ -3670,7 +3679,7 @@ async def _show_pmm_wizard_connector_step(update: Update, context: ContextTypes. cex_connectors = await get_available_cex_connectors(context.user_data, client) if not cex_connectors: - keyboard = [[InlineKeyboardButton("Back", callback_data="bots:controller_configs")]] + 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\.", @@ -3688,7 +3697,7 @@ async def _show_pmm_wizard_connector_step(update: Update, context: ContextTypes. row = [] if row: keyboard.append(row) - keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) await query.message.edit_text( r"*πŸ“ˆ PMM Mister \- New Config*" + "\n\n" @@ -3699,7 +3708,7 @@ async def _show_pmm_wizard_connector_step(update: Update, context: ContextTypes. except Exception as e: logger.error(f"Error in PMM connector step: {e}", exc_info=True) - keyboard = [[InlineKeyboardButton("Back", callback_data="bots:controller_configs")]] + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] await query.message.edit_text( format_error_message(f"Error: {str(e)}"), parse_mode="MarkdownV2", @@ -3745,7 +3754,7 @@ async def _show_pmm_wizard_pair_step(update: Update, context: ContextTypes.DEFAU row = [] if row: keyboard.append(row) - keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) await query.message.edit_text( r"*πŸ“ˆ PMM Mister \- New Config*" + "\n\n" @@ -3762,8 +3771,19 @@ async def handle_pmm_wizard_pair(update: Update, context: ContextTypes.DEFAULT_T config = get_controller_config(context) config["trading_pair"] = pair.upper() set_controller_config(context, config) - context.user_data["pmm_wizard_step"] = "leverage" - await _show_pmm_wizard_leverage_step(update, context) + + 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: @@ -3784,7 +3804,7 @@ async def _show_pmm_wizard_leverage_step(update: Update, context: ContextTypes.D InlineKeyboardButton("50x", callback_data="bots:pmm_leverage:50"), InlineKeyboardButton("75x", callback_data="bots:pmm_leverage:75"), ], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], ] await query.message.edit_text( @@ -3824,7 +3844,7 @@ async def _show_pmm_wizard_allocation_step(update: Update, context: ContextTypes InlineKeyboardButton("20%", callback_data="bots:pmm_alloc:0.2"), InlineKeyboardButton("50%", callback_data="bots:pmm_alloc:0.5"), ], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], ] await query.message.edit_text( @@ -3859,10 +3879,10 @@ async def _show_pmm_wizard_spreads_step(update: Update, context: ContextTypes.DE context.user_data["pmm_wizard_step"] = "spreads" keyboard = [ - [InlineKeyboardButton("Tight: 0.5%, 1%", callback_data="bots:pmm_spreads:0.005,0.01")], - [InlineKeyboardButton("Normal: 1%, 2%", callback_data="bots:pmm_spreads:0.01,0.02")], - [InlineKeyboardButton("Wide: 2%, 3%, 5%", callback_data="bots:pmm_spreads:0.02,0.03,0.05")], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + [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( @@ -3892,7 +3912,9 @@ async def _show_pmm_wizard_tp_step(update: Update, context: ContextTypes.DEFAULT config = get_controller_config(context) connector = config.get("connector_name", "") pair = config.get("trading_pair", "") - spreads = config.get("buy_spreads", "0.01,0.02") + leverage = config.get("leverage", 20) + allocation = config.get("portfolio_allocation", 0.05) + spreads = config.get("buy_spreads", "0.0002,0.001") keyboard = [ [ @@ -3905,12 +3927,13 @@ async def _show_pmm_wizard_tp_step(update: Update, context: ContextTypes.DEFAULT 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:controller_configs")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], ] 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", @@ -3928,35 +3951,54 @@ async def handle_pmm_wizard_tp(update: Update, context: ContextTypes.DEFAULT_TYP async def _show_pmm_wizard_review_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """PMM Wizard Step 7: Review""" + """PMM Wizard Step 7: Review with copyable config format""" query = update.callback_query config = get_controller_config(context) - existing = context.user_data.get("controller_configs_list", []) - config["id"] = pmm_generate_id(config, existing) - set_controller_config(context, config) + # 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) + + context.user_data["bots_state"] = "pmm_wizard_input" + context.user_data["pmm_wizard_step"] = "review" + + # 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("✏️ Edit ID", callback_data="bots:pmm_edit_id"), - InlineKeyboardButton("βš™οΈ Advanced", callback_data="bots:pmm_edit_advanced"), - ], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], ] await query.message.edit_text( - r"*πŸ“ˆ PMM Mister \- Review*" + "\n\n" - f"*ID:* `{escape_markdown_v2(config.get('id', ''))}`" + "\n\n" - f"🏦 *Connector:* `{escape_markdown_v2(config.get('connector_name', ''))}`" + "\n" - f"πŸ”— *Pair:* `{escape_markdown_v2(config.get('trading_pair', ''))}`" + "\n" - f"⚑ *Leverage:* `{config.get('leverage', 20)}x`" + "\n" - f"πŸ’° *Allocation:* `{config.get('portfolio_allocation', 0.05)*100:.0f}%`" + "\n\n" - f"πŸ“Š *Spreads:* `{escape_markdown_v2(config.get('buy_spreads', ''))}`" + "\n" - f"🎯 *Take Profit:* `{config.get('take_profit', 0.0001)*100:.2f}%`" + "\n\n" - f"πŸ“ˆ *Base %:* `{config.get('min_base_pct', 0.1)*100:.0f}%` / " - f"`{config.get('target_base_pct', 0.2)*100:.0f}%` / " - f"`{config.get('max_base_pct', 0.4)*100:.0f}%`", + message_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard) ) @@ -3979,7 +4021,8 @@ async def handle_pmm_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> try: client = await get_bots_client() - result = await client.controllers.add_controller_config(config) + config_id = config.get("id", "") + result = await client.controllers.create_or_update_controller_config(config_id, config) if result.get("status") == "success" or "success" in str(result).lower(): keyboard = [ @@ -4035,6 +4078,128 @@ async def handle_pmm_edit_id(update: Update, context: ContextTypes.DEFAULT_TYPE) ) +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}" + + keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:pmm_review_back")]] + + 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("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) + ) + + 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) + ) + + 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) + ) + + +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) + + 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) + + set_controller_config(context, config) + await _show_pmm_wizard_review_step(update, context) + + async def handle_pmm_edit_advanced(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Show advanced settings""" query = update.callback_query @@ -4107,35 +4272,68 @@ async def process_pmm_wizard_input(update: Update, context: ContextTypes.DEFAULT if step == "trading_pair": config["trading_pair"] = user_input.upper() set_controller_config(context, config) - context.user_data["pmm_wizard_step"] = "leverage" - # Edit the wizard message - 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:controller_configs")], - ] - await context.bot.edit_message_text( - chat_id=chat_id, message_id=message_id, - text=r"*πŸ“ˆ PMM Mister \- New Config*" + "\n\n" - f"🏦 `{escape_markdown_v2(config.get('connector_name', ''))}` \\| πŸ”— `{escape_markdown_v2(config['trading_pair'])}`" + "\n\n" - r"*Step 3/7:* ⚑ Leverage", - parse_mode="MarkdownV2", - reply_markup=InlineKeyboardMarkup(keyboard) - ) + 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=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: + # 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=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) + ) 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("0.01%", callback_data="bots:pmm_tp:0.0001"), @@ -4147,11 +4345,13 @@ async def process_pmm_wizard_input(update: Update, context: ContextTypes.DEFAULT 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:controller_configs")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], ] await context.bot.edit_message_text( 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", @@ -4163,6 +4363,42 @@ async def process_pmm_wizard_input(update: Update, context: ContextTypes.DEFAULT 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(",")] @@ -4199,27 +4435,102 @@ async def process_pmm_wizard_input(update: Update, context: ContextTypes.DEFAULT 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""" + """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("✏️ Edit ID", callback_data="bots:pmm_edit_id"), - InlineKeyboardButton("βš™οΈ Advanced", callback_data="bots:pmm_edit_advanced"), - ], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], ] + await context.bot.edit_message_text( chat_id=chat_id, message_id=message_id, - text=r"*πŸ“ˆ PMM Mister \- Review*" + "\n\n" - f"*ID:* `{escape_markdown_v2(config.get('id', ''))}`" + "\n\n" - f"🏦 *Connector:* `{escape_markdown_v2(config.get('connector_name', ''))}`" + "\n" - f"πŸ”— *Pair:* `{escape_markdown_v2(config.get('trading_pair', ''))}`" + "\n" - f"⚑ *Leverage:* `{config.get('leverage', 20)}x`" + "\n" - f"πŸ’° *Allocation:* `{config.get('portfolio_allocation', 0.05)*100:.0f}%`" + "\n\n" - f"πŸ“Š *Spreads:* `{escape_markdown_v2(config.get('buy_spreads', ''))}`" + "\n" - f"🎯 *Take Profit:* `{config.get('take_profit', 0.0001)*100:.2f}%`", + text=message_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard) ) diff --git a/handlers/bots/controllers/pmm_mister/config.py b/handlers/bots/controllers/pmm_mister/config.py index 1402826..5966e44 100644 --- a/handlers/bots/controllers/pmm_mister/config.py +++ b/handlers/bots/controllers/pmm_mister/config.py @@ -35,8 +35,8 @@ "target_base_pct": 0.2, "min_base_pct": 0.1, "max_base_pct": 0.4, - "buy_spreads": "0.01,0.02", - "sell_spreads": "0.01,0.02", + "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, @@ -122,16 +122,16 @@ label="Buy Spreads", type="str", required=True, - hint="Comma-separated spreads (e.g. 0.01,0.02)", - default="0.01,0.02" + 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.01,0.02)", - default="0.01,0.02" + hint="Comma-separated spreads (e.g. 0.0002,0.001)", + default="0.0002,0.001" ), "buy_amounts_pct": ControllerField( name="buy_amounts_pct", From f3fbee8b5ab767e0a6fc29e2899ba35f5161e2ab Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 9 Dec 2025 21:49:16 -0300 Subject: [PATCH 06/51] (feat) improve dex commands --- handlers/dex/__init__.py | 12 +- handlers/dex/geckoterminal.py | 12 +- handlers/dex/liquidity.py | 285 ++++++++----- handlers/dex/pool_data.py | 6 +- handlers/dex/pools.py | 740 +++++++++++++++++++++++++++------- 5 files changed, 809 insertions(+), 246 deletions(-) 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/geckoterminal.py b/handlers/dex/geckoterminal.py index 7af878f..afd9f48 100644 --- a/handlers/dex/geckoterminal.py +++ b/handlers/dex/geckoterminal.py @@ -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) diff --git a/handlers/dex/liquidity.py b/handlers/dex/liquidity.py index 580d856..0a43cba 100644 --- a/handlers/dex/liquidity.py +++ b/handlers/dex/liquidity.py @@ -241,16 +241,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,27 +285,50 @@ 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 + # Get position value from pnl_summary + pnl_summary = pos.get('pnl_summary', {}) + position_value_quote = pnl_summary.get('current_total_value_quote') + + # Get values from pnl_summary + initial_value = pnl_summary.get('initial_value_quote', 0) + total_fees_value = pnl_summary.get('total_fees_value_quote', 0) + current_total_value = pnl_summary.get('current_total_value_quote', 0) + + # Build line with price indicator next to range prefix = f"{index}. " if index is not None else "β€’ " - line = f"{prefix}{pair} ({connector}) {status_emoji} {range_str}" + 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 amounts if available + # Add PnL + value + pending fees in USD (all in one line) 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 + initial_f = float(initial_value) if initial_value else 0 + current_f = float(current_total_value) if current_total_value else 0 + fees_f = float(total_fees_value) if total_fees_value else 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)) - 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}" + # Get quote token price for USD conversion + quote_price = pos.get('quote_token_price', pos.get('quote_price', 1.0)) + try: + quote_price_f = float(quote_price) if quote_price else 1.0 + except (ValueError, TypeError): + quote_price_f = 1.0 + + # Convert to USD + initial_usd = initial_f * quote_price_f + current_usd = current_f * quote_price_f + fees_usd = fees_f * quote_price_f + + # PnL = current value - initial + pnl_usd = current_usd - initial_usd + pnl_sign = "+" if pnl_usd >= 0 else "" + + if current_usd > 0 or initial_usd > 0: + # Format: PnL: -$25.12 | Value: $41.23 | 🎁 $3.70 + parts = [] + parts.append(f"PnL: {pnl_sign}${abs(pnl_usd):.2f}") + parts.append(f"Value: ${current_usd:.2f}") + if fees_usd > 0.01: + parts.append(f"🎁 ${fees_usd:.2f}") + line += "\n " + " | ".join(parts) except (ValueError, TypeError): pass @@ -286,15 +336,9 @@ def _format_compact_position_line(pos: dict, token_cache: dict = None, index: in def _format_closed_position_line(pos: dict, token_cache: dict = None) -> str: - """Format a closed position as a compact line + """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: +$X | 🎁 $X | Xd ago """ token_cache = token_cache or {} @@ -307,65 +351,66 @@ 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) + # 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: + decimals = 8 + range_str = f"[{lower_f:.{decimals}f}-{upper_f:.{decimals}f}]" + except (ValueError, TypeError): + pass + + # Get PnL data 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 + initial_value = pnl_summary.get('initial_value_quote', 0) or 0 + current_total_value = pnl_summary.get('current_total_value_quote', 0) or 0 + total_fees_value = pnl_summary.get('total_fees_value_quote', 0) or 0 + # Get quote token price for USD conversion + quote_price = pos.get('quote_token_price', pos.get('quote_price', 1.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 - else: - direction_emoji = "πŸ“‰" # Price went down, you have more base - else: - direction_emoji = "➑️" # Price stayed in range + quote_price_f = float(quote_price) if quote_price else 1.0 except (ValueError, TypeError): - direction_emoji = "" - - # 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 = "" + quote_price_f = 1.0 + # Convert to USD try: - base_fee_f = float(base_fee) - quote_fee_f = float(quote_fee) - - 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}") - - if fee_parts: - fees_str = f"πŸ’° {' + '.join(fee_parts)}" - else: - fees_str = "πŸ’° 0" + initial_usd = float(initial_value) * quote_price_f + current_usd = float(current_total_value) * quote_price_f + fees_usd = float(total_fees_value) * quote_price_f + pnl_usd = current_usd - initial_usd + pnl_sign = "+" if pnl_usd >= 0 else "" except (ValueError, TypeError): - pass + pnl_usd = 0 + fees_usd = 0 + pnl_sign = "" # 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 + parts = [] + parts.append(f"PnL: {pnl_sign}${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 # ============================================ @@ -401,27 +446,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, @@ -491,7 +556,7 @@ 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) help_text += escape_markdown_v2(line) + "\n" @@ -532,19 +597,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 @@ -582,16 +647,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: diff --git a/handlers/dex/pool_data.py b/handlers/dex/pool_data.py index c4f047b..2e2e68d 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 diff --git a/handlers/dex/pools.py b/handlers/dex/pools.py index 7892191..3761c0a 100644 --- a/handlers/dex/pools.py +++ b/handlers/dex/pools.py @@ -278,46 +278,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 +397,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 +410,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 +464,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 +546,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: @@ -618,8 +641,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 @@ -1006,14 +1029,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}") @@ -1097,20 +1141,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 +1346,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 +1358,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 +1369,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: @@ -1456,13 +1595,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 +1620,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 +1653,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 +1686,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 +1744,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,6 +1771,14 @@ 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: @@ -1636,6 +1802,7 @@ 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 ) @@ -1665,22 +1832,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 +1883,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) @@ -2507,6 +2682,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 @@ -2765,19 +3043,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 +3132,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" @@ -2886,13 +3192,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 +3282,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 +3352,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 +3398,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 +3418,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: @@ -3365,6 +3759,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 = ( @@ -3401,11 +3817,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} @@ -3488,7 +3904,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 +3912,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 +3953,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 +4007,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 From a4f52cdab0d7211577355d87533da9a98c5be643 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 9 Dec 2025 23:32:39 -0300 Subject: [PATCH 07/51] (feat) add env example --- .env.example | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f77373c --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Telegram Bot +TELEGRAM_TOKEN=your_telegram_bot_token +AUTHORIZED_USERS=your_telegram_user_id + +# Pydantic gateway key (optional, for AI features) +PYDANTIC_GATEWAY_KEY=your_openai_key_here From 5dd556cec8e9f5076296cabfb84e1bcdfd3e694d Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 9 Dec 2025 23:34:58 -0300 Subject: [PATCH 08/51] (feat) support only ethereum and solana --- handlers/config/gateway/wallets.py | 4 ++-- main.py | 25 ++++++++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/handlers/config/gateway/wallets.py b/handlers/config/gateway/wallets.py index 0c95d68..97e4724 100644 --- a/handlers/config/gateway/wallets.py +++ b/handlers/config/gateway/wallets.py @@ -157,8 +157,8 @@ async def prompt_add_wallet_chain(query, context: ContextTypes.DEFAULT_TYPE) -> include_gateway=True ) - # 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 + diff --git a/main.py b/main.py index 32e47b3..3463380 100644 --- a/main.py +++ b/main.py @@ -42,7 +42,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"), ], ] @@ -302,8 +306,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"], From 73b7c2c278ac531223f91633b2b9d2387ef2cf71 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 9 Dec 2025 23:35:15 -0300 Subject: [PATCH 09/51] (feat) improve pools formatting --- handlers/dex/pools.py | 44 ++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/handlers/dex/pools.py b/handlers/dex/pools.py index 3761c0a..98203a7 100644 --- a/handlers/dex/pools.py +++ b/handlers/dex/pools.py @@ -1950,17 +1950,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}]") @@ -1978,6 +1983,13 @@ def _format_position_detail(pos: dict, token_cache: dict = None, detailed: bool lines.append("") # Separator + # Get quote token price for USD conversion + quote_price = pos.get('quote_token_price', pos.get('quote_price', 1.0)) + try: + quote_price_f = float(quote_price) if quote_price else 1.0 + except (ValueError, TypeError): + quote_price_f = 1.0 + # Current holdings if base_amount or quote_amount: try: @@ -1988,47 +2000,49 @@ 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}") + lines.append(f"⚠️ IL: ${il_val:.2f}") except (ValueError, TypeError): pass - # Fees earned + # Fees earned - convert to USD 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}") + fees_val = float(total_fees) * quote_price_f + lines.append(f"🎁 Fees earned: ${fees_val:.2f}") except (ValueError, TypeError): pass From 90d5d85caf797ebf663a4681d3b1f7ecf3d9ef4d Mon Sep 17 00:00:00 2001 From: nikspz <83953535+nikspz@users.noreply.github.com> Date: Wed, 10 Dec 2025 20:21:52 +0700 Subject: [PATCH 10/51] Update README.md with clone repo --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 9384d90bcee351beff585c39a2ecde1c4b0179f0 Mon Sep 17 00:00:00 2001 From: david-hummingbot <85695272+david-hummingbot@users.noreply.github.com> Date: Thu, 11 Dec 2025 00:34:16 +0800 Subject: [PATCH 11/51] fix /start command error --- main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 3463380..13e92c0 100644 --- a/main.py +++ b/main.py @@ -277,7 +277,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: """ @@ -346,7 +346,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: """ From 380422e939c8c97b34eb33da95c99b65686d35f1 Mon Sep 17 00:00:00 2001 From: david-hummingbot <85695272+david-hummingbot@users.noreply.github.com> Date: Thu, 11 Dec 2025 02:24:36 +0800 Subject: [PATCH 12/51] Add checks for condor_bot_data.pickle existence Ensure condor_bot_data.pickle is a file and not a directory. --- setup-environment.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/setup-environment.sh b/setup-environment.sh index d9984a4..f8d76b2 100644 --- a/setup-environment.sh +++ b/setup-environment.sh @@ -37,14 +37,26 @@ echo "" echo "Installing Chrome for Plotly image generation..." plotly_get_chrome || kaleido_get_chrome || python -c "import kaleido; kaleido.get_chrome_sync()" echo "" +echo "Ensuring condor_bot_data.pickle exists as a file..." +# Create pickle file if it doesn't exist or if it's a directory +# This prevents Docker from creating it as a directory when mounting +if [ -d condor_bot_data.pickle ]; then + echo "WARNING: condor_bot_data.pickle is a directory. Removing..." + rm -rf condor_bot_data.pickle +fi +if [ ! -f condor_bot_data.pickle ]; then + echo "Creating condor_bot_data.pickle..." + touch condor_bot_data.pickle +fi 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 "" From 943582fd6e274462e476176be085d0f0f3845383 Mon Sep 17 00:00:00 2001 From: david-hummingbot <85695272+david-hummingbot@users.noreply.github.com> Date: Thu, 11 Dec 2025 04:20:11 +0800 Subject: [PATCH 13/51] Simplify setup by removing pickle file handling Removed checks and creation for condor_bot_data.pickle file. --- setup-environment.sh | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/setup-environment.sh b/setup-environment.sh index f8d76b2..f6ba6ce 100644 --- a/setup-environment.sh +++ b/setup-environment.sh @@ -37,17 +37,9 @@ echo "" echo "Installing Chrome for Plotly image generation..." plotly_get_chrome || kaleido_get_chrome || python -c "import kaleido; kaleido.get_chrome_sync()" echo "" -echo "Ensuring condor_bot_data.pickle exists as a file..." -# Create pickle file if it doesn't exist or if it's a directory -# This prevents Docker from creating it as a directory when mounting -if [ -d condor_bot_data.pickle ]; then - echo "WARNING: condor_bot_data.pickle is a directory. Removing..." - rm -rf condor_bot_data.pickle -fi -if [ ! -f condor_bot_data.pickle ]; then - echo "Creating condor_bot_data.pickle..." - touch condor_bot_data.pickle -fi +echo "Ensuring data directory exists for persistence..." +mkdir -p data + echo "===================================" echo " How to Run Condor" echo "===================================" From cdd610736ad50cf75037cbd1133cc2b0e7ba06de Mon Sep 17 00:00:00 2001 From: david-hummingbot <85695272+david-hummingbot@users.noreply.github.com> Date: Thu, 11 Dec 2025 04:20:47 +0800 Subject: [PATCH 14/51] Implement persistence handling for Condor bot Added a function to build a persistence object for local and Docker use, with environment variable support. --- main.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 13e92c0..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 @@ -482,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 = ( From ff4f039e2eebaf2b32ba8751e3885bc1e2f079f7 Mon Sep 17 00:00:00 2001 From: david-hummingbot <85695272+david-hummingbot@users.noreply.github.com> Date: Thu, 11 Dec 2025 04:21:13 +0800 Subject: [PATCH 15/51] Change volume mapping for condor bot data Updated volume mapping for condor bot data. --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From d931547d54632f17f7b290af7712f384a2e87368 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 01:04:51 -0300 Subject: [PATCH 16/51] (feat) improve controllers cancel option --- handlers/bots/controller_handlers.py | 2 +- handlers/bots/menu.py | 33 +++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/handlers/bots/controller_handlers.py b/handlers/bots/controller_handlers.py index cd3dd91..5e1a3eb 100644 --- a/handlers/bots/controller_handlers.py +++ b/handlers/bots/controller_handlers.py @@ -552,7 +552,7 @@ async def handle_gs_wizard_amount(update: Update, context: ContextTypes.DEFAULT_ 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\\._", + r"_Fetching market data and generating chart\.\.\._", parse_mode="MarkdownV2" ) diff --git a/handlers/bots/menu.py b/handlers/bots/menu.py index d1c1835..c940596 100644 --- a/handlers/bots/menu.py +++ b/handlers/bots/menu.py @@ -120,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( @@ -136,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, From bf24977df1c224293ca8638bec2c0bc7edff5f62 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 01:05:02 -0300 Subject: [PATCH 17/51] (feat) add wallet selection for networks --- handlers/config/gateway/wallets.py | 481 +++++++++++++++++++++++++--- handlers/config/user_preferences.py | 125 ++++++++ 2 files changed, 553 insertions(+), 53 deletions(-) diff --git a/handlers/config/gateway/wallets.py b/handlers/config/gateway/wallets.py index 97e4724..008acc6 100644 --- a/handlers/config/gateway/wallets.py +++ b/handlers/config/gateway/wallets.py @@ -6,11 +6,18 @@ 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 @@ -54,37 +61,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 +131,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 +166,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") @@ -192,6 +275,178 @@ 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: + header, server_online, gateway_running = await build_config_message_header( + "πŸ”‘ Wallet Details", + include_gateway=True + ) + + 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: + header, server_online, gateway_running = await build_config_message_header( + "🌐 Edit Networks", + include_gateway=True + ) + + 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: @@ -372,9 +627,12 @@ async def remove_wallet(query, context: ContextTypes.DEFAULT_TYPE, chain: str, a client = await server_manager.get_default_client() - # 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 @@ -453,51 +711,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 +787,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/user_preferences.py b/handlers/config/user_preferences.py index 7cd4843..5238035 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,113 @@ 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 [] + + # ============================================ # UTILITY FUNCTIONS # ============================================ From 8298113834359614bf5140c48b9fa442a021f7ba Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 02:02:02 -0300 Subject: [PATCH 18/51] (feat) add decorators for gateway and hbot api requirements --- utils/auth.py | 134 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) 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 From 9e7a389d0b0ea8474102269203dfa61969278a95 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 02:02:09 -0300 Subject: [PATCH 19/51] (feat) bump hbot client version --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 98ccba7..2f98647 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-telegram-bot[job-queue] -hummingbot-api-client==1.2.4 +hummingbot-api-client==1.2.5 python-dotenv pytest pre-commit From d60f49973dc0b88c2234b26dd2e1f674fc27a624 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 02:02:18 -0300 Subject: [PATCH 20/51] (feat) add defualt server by chat id --- servers.py | 99 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 9 deletions(-) diff --git a/servers.py b/servers.py index cd79224..e653826 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: @@ -279,10 +357,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 +369,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() From c1ec7fe5d6321573ec88c748a10bd30f16c5be08 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 02:02:25 -0300 Subject: [PATCH 21/51] (feat) improve dex menu --- handlers/dex/liquidity.py | 118 +++++++++++++++++++++----------------- handlers/dex/menu.py | 17 +++++- handlers/dex/swap.py | 19 +++--- 3 files changed, 92 insertions(+), 62 deletions(-) diff --git a/handlers/dex/liquidity.py b/handlers/dex/liquidity.py index 0a43cba..629c7b2 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"]: @@ -217,12 +224,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', '')) @@ -289,43 +300,42 @@ def _format_compact_position_line(pos: dict, token_cache: dict = None, index: in pnl_summary = pos.get('pnl_summary', {}) position_value_quote = pnl_summary.get('current_total_value_quote') - # Get values from pnl_summary - initial_value = pnl_summary.get('initial_value_quote', 0) - total_fees_value = pnl_summary.get('total_fees_value_quote', 0) - current_total_value = pnl_summary.get('current_total_value_quote', 0) + # Get values from pnl_summary (all values are in quote token units) + total_pnl_quote = pnl_summary.get('total_pnl_quote', 0) + total_fees_quote = pnl_summary.get('total_fees_value_quote', 0) + current_lp_value_quote = pnl_summary.get('current_lp_value_quote', 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 in USD (all in one line) + # Add PnL + value + pending fees, converted to USD try: - initial_f = float(initial_value) if initial_value else 0 - current_f = float(current_total_value) if current_total_value else 0 - fees_f = float(total_fees_value) if total_fees_value else 0 + pnl_f = float(total_pnl_quote) if total_pnl_quote else 0 + fees_f = float(total_fees_quote) if total_fees_quote else 0 + lp_value_f = float(current_lp_value_quote) if current_lp_value_quote else 0 # Get quote token price for USD conversion - quote_price = pos.get('quote_token_price', pos.get('quote_price', 1.0)) - try: - quote_price_f = float(quote_price) if quote_price else 1.0 - except (ValueError, TypeError): - quote_price_f = 1.0 + # All pnl_summary values are in quote token units, so we just need quote price + quote_price = token_prices.get(quote_symbol, 1.0) - # Convert to USD - initial_usd = initial_f * quote_price_f - current_usd = current_f * quote_price_f - fees_usd = fees_f * quote_price_f + # Convert from quote token to USD + pnl_usd = pnl_f * quote_price + fees_usd = fees_f * quote_price + value_usd = lp_value_f * quote_price - # PnL = current value - initial - pnl_usd = current_usd - initial_usd - pnl_sign = "+" if pnl_usd >= 0 else "" + # Debug logging + logger.info(f"Position {index}: lp_value={lp_value_f:.4f} {quote_symbol} @ ${quote_price:.2f} = ${value_usd:.2f}, pnl={pnl_f:.4f} {quote_symbol} = ${pnl_usd:.2f}") - if current_usd > 0 or initial_usd > 0: - # Format: PnL: -$25.12 | Value: $41.23 | 🎁 $3.70 + if value_usd > 0 or pnl_f != 0: + # Format: PnL: -$25.12 | Value: $63.45 | 🎁 $3.70 parts = [] - parts.append(f"PnL: {pnl_sign}${abs(pnl_usd):.2f}") - parts.append(f"Value: ${current_usd:.2f}") + 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 fees_usd > 0.01: parts.append(f"🎁 ${fees_usd:.2f}") line += "\n " + " | ".join(parts) @@ -335,12 +345,13 @@ def _format_compact_position_line(pos: dict, token_cache: dict = None, index: in return line -def _format_closed_position_line(pos: dict, token_cache: dict = None) -> str: +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) βœ“ [range] | PnL: +$X | 🎁 $X | Xd ago + 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', '')) @@ -369,30 +380,24 @@ def _format_closed_position_line(pos: dict, token_cache: dict = None) -> str: except (ValueError, TypeError): pass - # Get PnL data + # Get PnL data - use pre-calculated total_pnl_quote pnl_summary = pos.get('pnl_summary', {}) - initial_value = pnl_summary.get('initial_value_quote', 0) or 0 - current_total_value = pnl_summary.get('current_total_value_quote', 0) or 0 + 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 - # Get quote token price for USD conversion - quote_price = pos.get('quote_token_price', pos.get('quote_price', 1.0)) try: - quote_price_f = float(quote_price) if quote_price else 1.0 + pnl_f = float(total_pnl_quote) + fees_f = float(total_fees_value) except (ValueError, TypeError): - quote_price_f = 1.0 + pnl_f = 0 + fees_f = 0 + + # Get quote token price for USD conversion + quote_price = token_prices.get(quote_symbol, 1.0) # Convert to USD - try: - initial_usd = float(initial_value) * quote_price_f - current_usd = float(current_total_value) * quote_price_f - fees_usd = float(total_fees_value) * quote_price_f - pnl_usd = current_usd - initial_usd - pnl_sign = "+" if pnl_usd >= 0 else "" - except (ValueError, TypeError): - pnl_usd = 0 - fees_usd = 0 - pnl_sign = "" + 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', '')) @@ -401,9 +406,12 @@ def _format_closed_position_line(pos: dict, token_cache: dict = None) -> str: # Build line: "MET-USDC (met) βœ“ [0.31-0.32]" line = f"{pair} ({connector}) βœ“ {range_str}" - # Add PnL and fees on second line + # Add PnL and fees on second line in USD parts = [] - parts.append(f"PnL: {pnl_sign}${abs(pnl_usd):.2f}") + 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: @@ -417,6 +425,7 @@ def _format_closed_position_line(pos: dict, token_cache: dict = None) -> str: # 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" @@ -432,10 +441,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( @@ -499,13 +509,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: @@ -558,7 +570,7 @@ def get_closed_time(pos): if closed_positions: 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" @@ -883,6 +895,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: @@ -890,7 +904,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..07404b4 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,8 +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)""" +async def _fetch_balances(client, enabled_networks: set = None) -> dict: + """Fetch gateway/DEX balances (blockchain wallets like solana, ethereum) + + Args: + client: API client + enabled_networks: Optional set of enabled network IDs to filter by. + If None, shows all gateway networks. + """ from collections import defaultdict # Gateway/blockchain connectors contain these keywords @@ -135,6 +141,11 @@ async def _fetch_balances(client) -> dict: logger.debug(f"Skipping non-gateway connector: {connector_name}") continue + # Filter by enabled networks if specified + if enabled_networks and connector_lower not in enabled_networks: + logger.debug(f"Skipping disabled network: {connector_name}") + continue + if balances: # Use connector name as network identifier network = connector_lower diff --git a/handlers/dex/swap.py b/handlers/dex/swap.py index 4c0b44a..e94ac61 100644 --- a/handlers/dex/swap.py +++ b/handlers/dex/swap.py @@ -251,7 +251,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 +265,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(): @@ -511,13 +512,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 +554,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 +579,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 +655,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 +813,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 +826,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(): From 91680193420e3fd353a624001bf8f8ed1dd9bdf8 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 02:02:33 -0300 Subject: [PATCH 22/51] (feat) add server by default chat id --- handlers/config/server_context.py | 30 +++++++++++++++++++++-------- handlers/config/servers.py | 24 +++++++++++++++-------- handlers/config/user_preferences.py | 27 ++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/handlers/config/server_context.py b/handlers/config/server_context.py index 946b2ee..878af94 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,13 +149,13 @@ 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 gateway_running = False if include_gateway: - gateway_info, gateway_running = await get_gateway_status_info() + gateway_info, gateway_running = await get_gateway_status_info(chat_id) header += gateway_info header += "\n" diff --git a/handlers/config/servers.py b/handlers/config/servers.py index 2718aa8..38d1590 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,15 @@ 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 - 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") + 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") diff --git a/handlers/config/user_preferences.py b/handlers/config/user_preferences.py index 5238035..caccab6 100644 --- a/handlers/config/user_preferences.py +++ b/handlers/config/user_preferences.py @@ -594,6 +594,33 @@ def get_all_networks_for_chain(chain: str) -> list: 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 # ============================================ From c9fb0e6ecb37978b9bcfb01820846b8711f7c697 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 02:02:47 -0300 Subject: [PATCH 23/51] (feat) client by chat id --- handlers/bots/__init__.py | 3 +- handlers/bots/_shared.py | 15 ++++++-- handlers/bots/controller_handlers.py | 56 ++++++++++++++++++---------- handlers/bots/menu.py | 21 +++++++---- handlers/portfolio.py | 55 +++++++++++++++++++++++++-- 5 files changed, 114 insertions(+), 36 deletions(-) diff --git a/handlers/bots/__init__.py b/handlers/bots/__init__.py index 42090f0..5b0aab8 100644 --- a/handlers/bots/__init__.py +++ b/handlers/bots/__init__.py @@ -149,12 +149,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") diff --git a/handlers/bots/_shared.py b/handlers/bots/_shared.py index 376db54..5c05569 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 diff --git a/handlers/bots/controller_handlers.py b/handlers/bots/controller_handlers.py index 5e1a3eb..56202c2 100644 --- a/handlers/bots/controller_handlers.py +++ b/handlers/bots/controller_handlers.py @@ -102,9 +102,10 @@ def _format_config_line(cfg: dict, index: int) -> str: 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""" 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 @@ -255,10 +256,11 @@ async def show_configs_list(update: Update, context: ContextTypes.DEFAULT_TYPE) 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 + chat_id = update.effective_chat.id # Fetch existing configs for sequence numbering try: - client = await get_bots_client() + 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: @@ -278,10 +280,11 @@ async def show_new_grid_strike_form(update: Update, context: ContextTypes.DEFAUL 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: - client = await get_bots_client() + client = await get_bots_client(chat_id) cex_connectors = await get_available_cex_connectors(context.user_data, client) if not cex_connectors: @@ -342,13 +345,14 @@ async def handle_gs_wizard_connector(update: Update, context: ContextTypes.DEFAU 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) config["trading_pair"] = pair.upper() set_controller_config(context, config) # Start background fetch of market data - asyncio.create_task(_background_fetch_market_data(context, config)) + asyncio.create_task(_background_fetch_market_data(context, config, chat_id)) # Move to side step context.user_data["gs_wizard_step"] = "side" @@ -564,6 +568,7 @@ async def handle_gs_wizard_amount(update: Update, context: ContextTypes.DEFAULT_ 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 + chat_id = update.effective_chat.id config = get_controller_config(context) connector = config.get("connector_name", "") @@ -611,7 +616,7 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT # Update the wizard message ID to the new loading message context.user_data["gs_wizard_message_id"] = loading_msg.message_id - client = await get_bots_client() + client = await get_bots_client(chat_id) current_price = await fetch_current_price(client, connector, pair) if current_price: @@ -1329,7 +1334,7 @@ async def handle_gs_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ) try: - client = await get_bots_client() + client = await get_bots_client(chat_id) result = await client.controllers.create_or_update_controller_config(config_id, config) # Clean up wizard state @@ -1378,7 +1383,7 @@ def _cleanup_wizard_state(context) -> None: clear_bots_state(context) -async def _background_fetch_market_data(context, config: dict) -> None: +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", "") @@ -1387,7 +1392,7 @@ async def _background_fetch_market_data(context, config: dict) -> None: return try: - client = await get_bots_client() + client = await get_bots_client(chat_id) # Fetch current price current_price = await fetch_current_price(client, connector, pair) @@ -1412,6 +1417,7 @@ async def _background_fetch_market_data(context, config: dict) -> None: 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: @@ -1434,7 +1440,7 @@ async def process_gs_wizard_input(update: Update, context: ContextTypes.DEFAULT_ set_controller_config(context, config) # Start background fetch of market data - asyncio.create_task(_background_fetch_market_data(context, config)) + asyncio.create_task(_background_fetch_market_data(context, config, chat_id)) # Move to side step context.user_data["gs_wizard_step"] = "side" @@ -2007,9 +2013,10 @@ async def handle_set_field(update: Update, context: ContextTypes.DEFAULT_TYPE, f 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() + client = await get_bots_client(chat_id) # Get available CEX connectors (with cache) cex_connectors = await get_available_cex_connectors(context.user_data, client) @@ -2077,6 +2084,7 @@ async def handle_select_connector(update: Update, context: ContextTypes.DEFAULT_ 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") @@ -2088,7 +2096,7 @@ async def fetch_and_apply_market_data(update: Update, context: ContextTypes.DEFA return try: - client = await get_bots_client() + client = await get_bots_client(chat_id) # Show loading message await query.message.edit_text( @@ -2251,6 +2259,7 @@ async def process_field_input(update: Update, context: ContextTypes.DEFAULT_TYPE 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: @@ -2303,7 +2312,7 @@ async def process_field_input(update: Update, context: ContextTypes.DEFAULT_TYPE ) try: - client = await get_bots_client() + client = await get_bots_client(chat_id) connector = config.get("connector_name") pair = config.get("trading_pair") side = config.get("side", SIDE_LONG) @@ -2380,6 +2389,7 @@ async def process_field_input(update: Update, context: ContextTypes.DEFAULT_TYPE 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 @@ -2401,7 +2411,7 @@ async def handle_save_config(update: Update, context: ContextTypes.DEFAULT_TYPE) return try: - client = await get_bots_client() + client = await get_bots_client(chat_id) # Save to backend using config id as the config_name config_name = config.get("id", "") @@ -2521,9 +2531,10 @@ async def handle_edit_config(update: Update, context: ContextTypes.DEFAULT_TYPE, 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() + client = await get_bots_client(chat_id) configs = await client.controllers.list_controller_configs() if not configs: @@ -3055,6 +3066,7 @@ async def process_deploy_field_input(update: Update, context: ContextTypes.DEFAU 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", {}) @@ -3081,7 +3093,7 @@ async def handle_execute_deploy(update: Update, context: ContextTypes.DEFAULT_TY ) 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( @@ -3248,11 +3260,12 @@ async def show_deploy_config_step(update: Update, context: ContextTypes.DEFAULT_ 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 if creds == "_show": # Show available credentials profiles try: - client = await get_bots_client() + client = await get_bots_client(chat_id) available_creds = await _get_available_credentials(client) except Exception: available_creds = ["master_account"] @@ -3562,7 +3575,7 @@ async def process_deploy_custom_name_input(update: Update, context: ContextTypes logger.error(f"Error updating deploy message: {e}") try: - client = await get_bots_client() + client = await get_bots_client(chat_id) result = await client.bot_orchestration.deploy_v2_controllers( instance_name=custom_name, @@ -3653,9 +3666,10 @@ async def process_deploy_custom_name_input(update: Update, context: ContextTypes 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: - client = await get_bots_client() + 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: @@ -3673,9 +3687,10 @@ async def show_new_pmm_mister_form(update: Update, context: ContextTypes.DEFAULT 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 try: - client = await get_bots_client() + client = await get_bots_client(chat_id) cex_connectors = await get_available_cex_connectors(context.user_data, client) if not cex_connectors: @@ -4007,6 +4022,7 @@ async def _show_pmm_wizard_review_step(update: Update, context: ContextTypes.DEF 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) is_valid, error = pmm_validate_config(config) @@ -4020,7 +4036,7 @@ async def handle_pmm_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> return try: - client = await get_bots_client() + client = await get_bots_client(chat_id) config_id = config.get("id", "") result = await client.controllers.create_or_update_controller_config(config_id, config) diff --git a/handlers/bots/menu.py b/handlers/bots/menu.py index c940596..edddf92 100644 --- a/handlers/bots/menu.py +++ b/handlers/bots/menu.py @@ -82,13 +82,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 @@ -183,6 +184,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 @@ -195,7 +197,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) @@ -421,6 +423,7 @@ 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)""" 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", {}) @@ -462,7 +465,7 @@ async def show_controller_detail(update: Update, context: ContextTypes.DEFAULT_T message_replaced = False # Track if we've sent a new message (e.g., loading message) 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 @@ -672,6 +675,7 @@ async def handle_stop_controller(update: Update, context: ContextTypes.DEFAULT_T 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", []) @@ -690,7 +694,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( @@ -904,6 +908,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") @@ -929,7 +934,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": @@ -1007,6 +1012,7 @@ 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""" + chat_id = update.effective_chat.id field_name = context.user_data.get("editing_ctrl_field") bot_name = context.user_data.get("current_bot_name") ctrl_config = context.user_data.get("current_controller_config") @@ -1035,7 +1041,7 @@ async def process_controller_field_input(update: Update, context: ContextTypes.D # Validate and update try: - client = await get_bots_client() + client = await get_bots_client(chat_id) # Build config update if field_name == "take_profit": @@ -1127,6 +1133,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: @@ -1141,7 +1148,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, diff --git a/handlers/portfolio.py b/handlers/portfolio.py index 84bd457..e7c0a55 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. @@ -553,6 +588,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 +606,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: @@ -659,6 +695,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}") @@ -822,14 +863,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: @@ -858,6 +899,12 @@ async def refresh_portfolio_dashboard(update: Update, context: ContextTypes.DEFA client, days ) + # 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'): From 4a957ae7bfdd8346c148bc9afd4a15df0fc6d10e Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 02:04:55 -0300 Subject: [PATCH 24/51] (feat) unify in setup env --- .env.example | 6 ------ setup-environment.sh | 13 +++++++------ 2 files changed, 7 insertions(+), 12 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index f77373c..0000000 --- a/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -# Telegram Bot -TELEGRAM_TOKEN=your_telegram_bot_token -AUTHORIZED_USERS=your_telegram_user_id - -# Pydantic gateway key (optional, for AI features) -PYDANTIC_GATEWAY_KEY=your_openai_key_here diff --git a/setup-environment.sh b/setup-environment.sh index f6ba6ce..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,7 +36,7 @@ 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 From 3ac5cb99f3cc65608a3ec1c3909b0368912207c3 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 17:43:03 -0300 Subject: [PATCH 25/51] (feat) improve dex menu --- handlers/dex/menu.py | 144 ++++++++++++++++++++++++++++++++++--------- handlers/dex/swap.py | 7 ++- 2 files changed, 120 insertions(+), 31 deletions(-) diff --git a/handlers/dex/menu.py b/handlers/dex/menu.py index 07404b4..429bb77 100644 --- a/handlers/dex/menu.py +++ b/handlers/dex/menu.py @@ -101,19 +101,15 @@ def _format_price(price) -> str: return str(price) -async def _fetch_balances(client, enabled_networks: set = None) -> dict: +async def _fetch_balances(client, refresh: bool = False) -> dict: """Fetch gateway/DEX balances (blockchain wallets like solana, ethereum) Args: - client: API client - enabled_networks: Optional set of enabled network IDs to filter by. - If None, shows all gateway networks. + client: The API client + refresh: If True, force refresh from exchanges. If False, use cached state (default) """ from collections import defaultdict - # Gateway/blockchain connectors contain these keywords - GATEWAY_KEYWORDS = ["solana", "ethereum", "polygon", "arbitrum", "base", "avalanche", "optimism"] - data = { "balances_by_network": defaultdict(list), "total_value": 0, @@ -124,7 +120,24 @@ async def _fetch_balances(client, enabled_networks: set = None) -> 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 @@ -135,17 +148,28 @@ async def _fetch_balances(client, enabled_networks: set = None) -> 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 - # Filter by enabled networks if specified - if enabled_networks and connector_lower not in enabled_networks: - logger.debug(f"Skipping disabled network: {connector_name}") - continue - if balances: # Use connector name as network identifier network = connector_lower @@ -178,6 +202,48 @@ async def _fetch_balances(client, enabled_networks: set = None) -> 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 = { @@ -373,12 +439,16 @@ 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 ) -> 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) """ gateway_data = {"balances_by_network": {}, "lp_positions": [], "total_value": 0, "token_cache": {}} @@ -386,13 +456,24 @@ async def _load_menu_data_background( client = await get_client() # 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) @@ -454,11 +535,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 @@ -512,7 +596,7 @@ async def show_dex_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N # Spawn background task to load data - user can navigate away without waiting 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) ) context.user_data[DEX_LOADING_TASK_KEY] = task @@ -531,12 +615,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/swap.py b/handlers/dex/swap.py index e94ac61..5734c1d 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__) @@ -420,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} From cf2dad63d2bf8b16aa21b80e1aee62158c5947d7 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 17:43:11 -0300 Subject: [PATCH 26/51] (feat) improve grid limits --- handlers/bots/controller_handlers.py | 17 ++++++++++++----- handlers/bots/controllers/grid_strike/config.py | 8 ++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/handlers/bots/controller_handlers.py b/handlers/bots/controller_handlers.py index 56202c2..bb090ca 100644 --- a/handlers/bots/controller_handlers.py +++ b/handlers/bots/controller_handlers.py @@ -791,7 +791,7 @@ async def handle_gs_accept_prices(update: Update, context: ContextTypes.DEFAULT_ # Validate price ordering based on side # LONG: limit_price < start_price < end_price - # SHORT: start_price < end_price < limit_price + # SHORT: end_price < start_price < limit_price validation_error = None if side == SIDE_LONG: if not (limit_price < start_price < end_price): @@ -801,11 +801,11 @@ async def handle_gs_accept_prices(update: Update, context: ContextTypes.DEFAULT_ f"Current: `{limit_price:,.6g}` < `{start_price:,.6g}` < `{end_price:,.6g}`" ) else: # SHORT - if not (start_price < end_price < limit_price): + if not (end_price < start_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}`" + "Required: `end < start < limit`\n" + f"Current: `{end_price:,.6g}` < `{start_price:,.6g}` < `{limit_price:,.6g}`" ) if validation_error: @@ -1682,6 +1682,10 @@ async def _update_wizard_message_for_prices(update: Update, context: ContextType 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) @@ -1703,7 +1707,10 @@ async def edit_text(self, 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)})() + 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) diff --git a/handlers/bots/controllers/grid_strike/config.py b/handlers/bots/controllers/grid_strike/config.py index 2d472e8..3969de9 100644 --- a/handlers/bots/controllers/grid_strike/config.py +++ b/handlers/bots/controllers/grid_strike/config.py @@ -248,11 +248,11 @@ def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: f"Got: {limit_price:.6g} < {start_price:.6g} < {end_price:.6g}" ) else: - # SHORT: start_price < end_price < limit_price - if not (start_price < end_price < limit_price): + # SHORT: end_price < start_price < limit_price + if not (end_price < start_price < limit_price): return False, ( - f"Invalid prices for SHORT: require start < end < limit. " - f"Got: {start_price:.6g} < {end_price:.6g} < {limit_price:.6g}" + f"Invalid prices for SHORT: require end < start < limit. " + f"Got: {end_price:.6g} < {start_price:.6g} < {limit_price:.6g}" ) return True, None From 5737049b15da33edb4758dabb78815ff16409951 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 17:43:15 -0300 Subject: [PATCH 27/51] (feat) improve portfolio --- handlers/portfolio.py | 50 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/handlers/portfolio.py b/handlers/portfolio.py index e7c0a55..a7182a3 100644 --- a/handlers/portfolio.py +++ b/handlers/portfolio.py @@ -491,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) """ @@ -507,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( @@ -516,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( @@ -633,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)) @@ -773,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) @@ -819,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]) @@ -846,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() @@ -895,8 +923,9 @@ 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 @@ -961,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) From 244a081d24228dbedf715599ad7eb2e982a01e1b Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 17:43:28 -0300 Subject: [PATCH 28/51] (feat) add refresh func --- utils/trading_data.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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) From a3cf060fc70811add9ac7945e0c94763e485507b Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 19:06:39 -0300 Subject: [PATCH 29/51] (feat) improve pools handling --- handlers/dex/pools.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/handlers/dex/pools.py b/handlers/dex/pools.py index 98203a7..90fbf04 100644 --- a/handlers/dex/pools.py +++ b/handlers/dex/pools.py @@ -763,13 +763,18 @@ async def handle_plot_liquidity( try: client = await get_client() - # Fetch all pool infos in parallel + # Fetch all pool infos in parallel with individual timeouts + POOL_FETCH_TIMEOUT = 10 # seconds per pool + 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') @@ -785,6 +790,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 @@ -793,11 +801,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 @@ -854,7 +865,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}", From 617bbe24beafcc283583c3e29fcdb0703b2936cd Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 19:55:58 -0300 Subject: [PATCH 30/51] (feat) apply mikes suggestion --- handlers/config/gateway/networks.py | 394 ++++++---------------------- 1 file changed, 84 insertions(+), 310 deletions(-) diff --git a/handlers/config/gateway/networks.py b/handlers/config/gateway/networks.py index da3c024..5919398 100644 --- a/handlers/config/gateway/networks.py +++ b/handlers/config/gateway/networks.py @@ -102,17 +102,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,7 +109,7 @@ 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 @@ -129,42 +118,46 @@ async def show_network_details(query, context: ContextTypes.DEFAULT_TYPE, networ # 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 +175,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 +188,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) @@ -435,122 +325,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: From 892b2735680b72995178163053330eb1a84a4a38 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 19:56:11 -0300 Subject: [PATCH 31/51] (feat) improve server status --- handlers/config/server_context.py | 10 +++++++--- servers.py | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/handlers/config/server_context.py b/handlers/config/server_context.py index 878af94..f39d0de 100644 --- a/handlers/config/server_context.py +++ b/handlers/config/server_context.py @@ -152,11 +152,15 @@ async def build_config_message_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(chat_id) - 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/servers.py b/servers.py index e653826..34335be 100644 --- a/servers.py +++ b/servers.py @@ -299,13 +299,13 @@ 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 reasonable timeout 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=10, connect=5) ) try: From d41fe1c0aab2ad203e66afbe5839fa59566c1b83 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 19:56:18 -0300 Subject: [PATCH 32/51] (feat) improve bots formatting --- handlers/bots/__init__.py | 18 +- handlers/bots/menu.py | 554 +++++++++++++++++++++++++++----------- 2 files changed, 411 insertions(+), 161 deletions(-) diff --git a/handlers/bots/__init__.py b/handlers/bots/__init__.py index 5b0aab8..0cbca32 100644 --- a/handlers/bots/__init__.py +++ b/handlers/bots/__init__.py @@ -28,6 +28,8 @@ 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, @@ -488,6 +490,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) @@ -544,7 +557,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) diff --git a/handlers/bots/menu.py b/handlers/bots/menu.py index edddf92..3612828 100644 --- a/handlers/bots/menu.py +++ b/handlers/bots/menu.py @@ -225,80 +225,141 @@ 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 + lines.append("") + 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 section + lines.append("") + pnl_emoji = "🟒" if pnl >= 0 else "πŸ”΄" + lines.append(f"*P&L:* {pnl_emoji} `{escape_markdown_v2(f'{pnl:+.2f}')}` \\| πŸ’° *R:* `{escape_markdown_v2(f'{realized:+.2f}')}` \\| πŸ“Š *U:* `{escape_markdown_v2(f'{unrealized:+.2f}')}`") + vol_str = f"{volume/1000:.1f}k" if volume >= 1000 else f"{volume:.0f}" + lines.append(f"πŸ“¦ 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("") + 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*") + lines.append(f" {pnl_emoji} P&L: `{escape_markdown_v2(f'{total_pnl:+.2f}')}` \\(R: `{escape_markdown_v2(f'{total_realized:+.2f}')}` / U: `{escape_markdown_v2(f'{total_unrealized:+.2f}')}`\\)") + lines.append(f" πŸ“¦ Volume: `{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([ @@ -364,6 +425,38 @@ async def show_bot_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, bo 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: """Shorten controller name intelligently @@ -721,6 +814,78 @@ 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 # ============================================ @@ -735,7 +900,7 @@ async def show_controller_chart(update: Update, context: ContextTypes.DEFAULT_TY 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") @@ -747,74 +912,40 @@ 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 query.message.photo 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("```") - - # 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("") - - # 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) - - 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}"), - ], + [InlineKeyboardButton("❌ Cancel", callback_data=f"bots:ctrl_idx:{controller_idx}")], ] reply_markup = InlineKeyboardMarkup(keyboard) - text_content = "\n".join(lines) # Handle photo messages (from controller detail view with chart) @@ -823,17 +954,37 @@ async def show_controller_edit(update: Update, context: ContextTypes.DEFAULT_TYP await query.message.delete() except Exception: pass - await query.message.chat.send_message( + 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 + + +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 + + 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.0002), + "take_profit": take_profit, + "manual_kill_switch": ctrl_config.get("manual_kill_switch", False), + } async def handle_controller_set_field(update: Update, context: ContextTypes.DEFAULT_TYPE, field_name: str) -> None: @@ -1011,48 +1162,99 @@ 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""" + """Process user input for controller bulk edit - parses key=value lines""" chat_id = update.effective_chat.id - field_name = context.user_data.get("editing_ctrl_field") 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] + # Delete user's input message for clean chat + try: + 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 + # Clear state context.user_data.pop("bots_state", None) - context.user_data.pop("editing_ctrl_field", None) + context.user_data.pop("ctrl_editable_fields", None) - # Parse value + # Show saving message + saving_text = f"πŸ’Ύ Saving configuration\\.\\.\\." try: - parsed_value = float(user_input) - except ValueError: - await update.message.reply_text( - f"❌ Invalid value\\. Please enter a number\\.", - parse_mode="MarkdownV2" - ) - return + 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[key] = value - # Validate and update try: client = await get_bots_client(chat_id) - # Build config update - if field_name == "take_profit": - update_config = { - "triple_barrier_config": { - "take_profit": parsed_value - } - } - else: - update_config = {field_name: parsed_value} - # Apply the update result = await client.controllers.update_bot_controller_config( bot_name=bot_name, @@ -1062,36 +1264,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) ) From 8fdad30d0c6daf75696e83bd81e7839223679b7b Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 10 Dec 2025 19:58:31 -0300 Subject: [PATCH 33/51] (feat) improve bots formatting --- handlers/bots/menu.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/handlers/bots/menu.py b/handlers/bots/menu.py index 3612828..977bb4e 100644 --- a/handlers/bots/menu.py +++ b/handlers/bots/menu.py @@ -268,8 +268,9 @@ async def show_bot_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, bo # P&L section lines.append("") + lines.append("*P&L*") pnl_emoji = "🟒" if pnl >= 0 else "πŸ”΄" - lines.append(f"*P&L:* {pnl_emoji} `{escape_markdown_v2(f'{pnl:+.2f}')}` \\| πŸ’° *R:* `{escape_markdown_v2(f'{realized:+.2f}')}` \\| πŸ“Š *U:* `{escape_markdown_v2(f'{unrealized:+.2f}')}`") + lines.append(f"{pnl_emoji} `{escape_markdown_v2(f'{pnl:+.2f}')}` \\| πŸ’° R: `{escape_markdown_v2(f'{realized:+.2f}')}` \\| πŸ“Š U: `{escape_markdown_v2(f'{unrealized:+.2f}')}`") vol_str = f"{volume/1000:.1f}k" if volume >= 1000 else f"{volume:.0f}" lines.append(f"πŸ“¦ Vol: `{escape_markdown_v2(vol_str)}`") From 26e4cbfbe7aa381452103a91e952485998db6b60 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 12 Dec 2025 01:11:42 +0800 Subject: [PATCH 34/51] add docker build workflow --- servers.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/servers.yml b/servers.yml index d337237..c0785b2 100644 --- a/servers.yml +++ b/servers.yml @@ -1,12 +1,7 @@ servers: -# remote: -# host: 212.1.12.23 -# port: 8000 -# username: donero -# password: barabit local: host: localhost port: 8000 username: admin password: admin -default_server: remote +default_server: local From 61b4f63fed562b4ea8e224697713052957ad699e Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 12 Dec 2025 01:13:49 +0800 Subject: [PATCH 35/51] add dockerignore and workflow --- .dockerignore | 12 +++++++++ .github/workflows/docker-build.yml | 43 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-build.yml 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 }} From 3aad1a67c352717ea111640e919cb577cadca776 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Fri, 12 Dec 2025 20:05:27 -0300 Subject: [PATCH 36/51] (feat) improve dex with pics --- handlers/dex/geckoterminal.py | 12 ++++++------ handlers/dex/pools.py | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/handlers/dex/geckoterminal.py b/handlers/dex/geckoterminal.py index afd9f48..0340197 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\\.\\.\\._", @@ -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\\.\\.\\._", @@ -1805,7 +1805,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( @@ -2557,7 +2557,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/pools.py b/handlers/dex/pools.py index 90fbf04..9743de7 100644 --- a/handlers/dex/pools.py +++ b/handlers/dex/pools.py @@ -1470,7 +1470,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" ) @@ -1546,7 +1546,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" ) @@ -1792,7 +1792,7 @@ async def handle_pool_combined_chart(update: Update, context: ContextTypes.DEFAU # 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( @@ -3817,7 +3817,7 @@ 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") @@ -3904,7 +3904,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) @@ -3917,7 +3917,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) From a0a991638c8d5535cf6c74ad2b6b10722178d083 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Fri, 12 Dec 2025 20:05:43 -0300 Subject: [PATCH 37/51] (feat) avoid api keys of routers and amms --- handlers/config/api_keys.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/handlers/config/api_keys.py b/handlers/config/api_keys.py index 8173625..1760a98 100644 --- a/handlers/config/api_keys.py +++ b/handlers/config/api_keys.py @@ -290,8 +290,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 From 1e1428d453a9b5a678ce6a9827bc318e9391d601 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Fri, 12 Dec 2025 20:05:50 -0300 Subject: [PATCH 38/51] (feat) improve grid strike menu --- handlers/bots/controllers/grid_strike/__init__.py | 13 +++++++++++++ handlers/bots/controllers/grid_strike/chart.py | 7 ++++++- handlers/bots/controllers/grid_strike/config.py | 14 +++++++++++--- 3 files changed, 30 insertions(+), 4 deletions(-) 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..0044e64 100644 --- a/handlers/bots/controllers/grid_strike/chart.py +++ b/handlers/bots/controllers/grid_strike/chart.py @@ -220,7 +220,12 @@ def generate_chart( 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 + # Use smart date formatting based on zoom level + tickformatstops=[ + dict(dtickrange=[None, 3600000], value="%H:%M"), # < 1 hour between ticks: show time only + dict(dtickrange=[3600000, 86400000], value="%H:%M\n%b %d"), # 1h-1day: show time and date + dict(dtickrange=[86400000, None], value="%b %d"), # > 1 day: show date only + ], tickangle=0, # Keep labels horizontal ), yaxis=dict( diff --git a/handlers/bots/controllers/grid_strike/config.py b/handlers/bots/controllers/grid_strike/config.py index 3969de9..7d03536 100644 --- a/handlers/bots/controllers/grid_strike/config.py +++ b/handlers/bots/controllers/grid_strike/config.py @@ -151,6 +151,14 @@ hint="Default: 0.0002", default=0.0002 ), + "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", @@ -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" ] From 09abd7c1ca9ec5c722ea03d50ca1cba88e239914 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Fri, 12 Dec 2025 20:05:55 -0300 Subject: [PATCH 39/51] (feat) merge menues --- handlers/bots/__init__.py | 92 ++ handlers/bots/controller_handlers.py | 1442 ++++++++++++++++++++++---- handlers/bots/menu.py | 302 +++--- 3 files changed, 1500 insertions(+), 336 deletions(-) diff --git a/handlers/bots/__init__.py b/handlers/bots/__init__.py index 0cbca32..d686307 100644 --- a/handlers/bots/__init__.py +++ b/handlers/bots/__init__.py @@ -35,6 +35,7 @@ show_bot_logs, handle_back_to_bot, handle_refresh_bot, + handle_refresh_controller, # Controller chart & edit show_controller_chart, show_controller_edit, @@ -46,6 +47,25 @@ 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, @@ -211,6 +231,67 @@ 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) @@ -519,6 +600,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}") @@ -581,6 +667,12 @@ async def bots_message_handler(update: Update, context: ContextTypes.DEFAULT_TYP # 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/controller_handlers.py b/handlers/bots/controller_handlers.py index bb090ca..a646e66 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,8 +112,42 @@ 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 @@ -108,89 +155,124 @@ async def show_controller_configs_menu(update: Update, context: ContextTypes.DEF 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) + selected = context.user_data.get("selected_configs", {}) # {config_id: True} + 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) - # Handle photo messages (e.g., coming back from prices step with chart) - if query.message.photo: + # Handle photo messages (use getattr for FakeMessage compatibility) + if getattr(query.message, 'photo', None): try: await query.message.delete() except Exception: @@ -201,21 +283,25 @@ async def show_controller_configs_menu(update: Update, context: ContextTypes.DEF reply_markup=reply_markup ) else: - await query.message.edit_text( - text_content, - parse_mode="MarkdownV2", - reply_markup=reply_markup - ) + 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)}") try: - if query.message.photo: + if getattr(query.message, 'photo', None): try: await query.message.delete() except Exception: @@ -235,9 +321,620 @@ async def show_controller_configs_menu(update: Update, context: ContextTypes.DEF pass +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", []) + + type_counts = _get_available_controller_types(configs) + current_type = context.user_data.get("configs_controller_type", "grid_strike") + + 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}") + ]) + + keyboard.append([ + InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs"), + ]) + + await query.message.edit_text( + "\n".join(lines), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + +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) + + +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", {}) + + if selected.get(config_id): + selected.pop(config_id, None) + else: + selected[config_id] = True + + context.user_data["selected_configs"] = selected + + page = context.user_data.get("configs_page", 0) + await show_controller_configs_menu(update, context, page) + + +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) + + +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_cfg_delete_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show delete confirmation dialog""" + 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] + + if not selected_ids: + await query.answer("No configs selected", show_alert=True) + return + + # 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)}`") + + lines.append("") + lines.append(r"⚠️ _This action cannot be undone\._") + + keyboard = [ + [ + InlineKeyboardButton("βœ… Yes, Delete", callback_data="bots:cfg_delete_execute"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs"), + ] + ] + + reply_markup = InlineKeyboardMarkup(keyboard) + text_content = "\n".join(lines) + + await query.message.edit_text( + text_content, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) + + +async def handle_cfg_delete_execute(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Execute deletion of selected configs""" + query = update.callback_query + chat_id = update.effective_chat.id + + selected = context.user_data.get("selected_configs", {}) + selected_ids = [cfg_id for cfg_id, is_sel in selected.items() if is_sel] + + if not selected_ids: + await query.answer("No configs selected", show_alert=True) + return + + # Show progress + await query.message.edit_text( + 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 = [] + + 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))) + + # Clear selection + context.user_data["selected_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( + "\n".join(lines), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + +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", []) + + if not selected_ids: + query = update.callback_query + await query.answer("No configs selected", show_alert=True) + return + + # 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 + + # Don't clear selection - keep it for when user comes back + + # Use existing deploy configure flow + await show_deploy_configure(update, context) + + +# ============================================ +# 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", []) + + 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.0002), + "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 + + 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) + + # 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 + + # Build message with key=value format + lines = [f"*Edit Config* \\({current_idx + 1}/{total}\\)", ""] + lines.append(f"`{escape_markdown_v2(config_id)}`") + lines.append("") + + # 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.append("```") + lines.append(config_text) + lines.append("```") + lines.append("") + lines.append("✏️ _Send `key=value` to update_") + + # Build keyboard - simplified, no field buttons + keyboard = [] + + # 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( + "\n".join(lines), + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) + + +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) + + if not config: + await query.answer("Config not found", show_alert=True) + return + + # 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)"), + } + + label, hint = field_labels.get(field_name, (field_name, "Enter value")) + + # 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)}_", + ] + + keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:cfg_edit_form")]] + + await query.message.edit_text( + "\n".join(lines), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + +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: + 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 + + # 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 + + # 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 + + # Update editable fields for display + context.user_data["cfg_editable_fields"] = _get_editable_config_fields(config) + + # Format updated fields + updated_lines = [f"`{escape_markdown_v2(k)}` \\= `{escape_markdown_v2(str(v))}`" for k, v in updates.items()] + + keyboard = [[InlineKeyboardButton("βœ… Continue", callback_data="bots:cfg_edit_form")]] + + 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) + ) + + +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) + + +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_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 + + config_id = config.get("id") + + 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]}") + + # 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 + + # 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 = [] + + 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")]] + + await query.message.edit_text( + "\n".join(lines), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + +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 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) + """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) # ============================================ @@ -505,8 +1202,9 @@ async def handle_gs_wizard_leverage(update: Update, context: ContextTypes.DEFAUL async def _show_wizard_amount_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Wizard Step 5: Enter Amount""" + """Wizard Step 5: Enter Amount with available balances""" query = update.callback_query + chat_id = update.effective_chat.id config = get_controller_config(context) connector = config.get("connector_name", "") @@ -517,6 +1215,83 @@ async def _show_wizard_amount_step(update: Update, context: ContextTypes.DEFAULT context.user_data["bots_state"] = "gs_wizard_input" context.user_data["gs_wizard_step"] = "total_amount_quote" + # 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: + client = await get_bots_client(chat_id) + balances = await get_cex_balances( + context.user_data, client, "master_account", ttl=30 + ) + + # 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 + + 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}" + + # 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("πŸ’΅ 100", callback_data="bots:gs_amount:100"), @@ -535,6 +1310,7 @@ async def _show_wizard_amount_step(update: Update, context: ContextTypes.DEFAULT f"🏦 *Connector:* `{escape_markdown_v2(connector)}`" + "\n" f"πŸ”— *Pair:* `{escape_markdown_v2(pair)}`" + "\n" f"🎯 *Side:* `{side}` \\| ⚑ *Leverage:* `{leverage}x`" + "\n\n" + + balance_text + r"*Step 5/7:* πŸ’° Total Amount \(Quote\)" + "\n\n" r"Select or type amount in quote currency:", parse_mode="MarkdownV2", @@ -566,7 +1342,7 @@ async def handle_gs_wizard_amount(update: Update, context: ContextTypes.DEFAULT_ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT_TYPE, interval: str = None) -> None: - """Wizard Step 6: Price Configuration with OHLC chart""" + """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) @@ -574,21 +1350,20 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT connector = config.get("connector_name", "") pair = config.get("trading_pair", "") side = config.get("side", SIDE_LONG) + total_amount = config.get("total_amount_quote", 1000) - # Get current interval (default 5m) + # Get current interval (default 1m for better NATR calculation) if interval is None: - interval = context.user_data.get("gs_chart_interval", "5m") + interval = context.user_data.get("gs_chart_interval", "1m") 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") try: # If no cached data or interval changed, fetch now - cached_interval = context.user_data.get("gs_candles_interval", "5m") + cached_interval = context.user_data.get("gs_candles_interval", "1m") need_refetch = interval != cached_interval if not current_price or need_refetch: @@ -613,7 +1388,6 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT ), parse_mode="MarkdownV2" ) - # Update the wizard message ID to the new loading message context.user_data["gs_wizard_message_id"] = loading_msg.message_id client = await get_bots_client(chat_id) @@ -621,10 +1395,19 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT if current_price: context.user_data["gs_current_price"] = current_price - candles = await fetch_candles(client, connector, pair, interval=interval, max_records=500) + # Fetch candles (100 records is enough for NATR calculation) + candles = await fetch_candles(client, connector, pair, interval=interval, max_records=100) context.user_data["gs_candles"] = candles context.user_data["gs_candles_interval"] = interval + # 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"] = {} + if not current_price: keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]] try: @@ -636,7 +1419,6 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT 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=( @@ -649,16 +1431,55 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT ) return - # Calculate auto prices only if not already set (preserve user edits) + # 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 + + # 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) + + # Calculate smart defaults based on NATR if not already set 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") + 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"] + # Only set these if not already configured + if not config.get("min_spread_between_orders") or config.get("min_spread_between_orders") == 0.0002: + config["min_spread_between_orders"] = suggestions["min_spread_between_orders"] + if not config.get("triple_barrier_config", {}).get("take_profit") or \ + config.get("triple_barrier_config", {}).get("take_profit") == 0.0001: + if "triple_barrier_config" not in config: + config["triple_barrier_config"] = GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy() + config["triple_barrier_config"]["take_profit"] = suggestions["take_profit"] + 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.0002) + 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 # Generate config ID with sequence number (if not already set) if not config.get("id"): @@ -667,6 +1488,19 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT 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" @@ -683,36 +1517,64 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT 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")], ] - # Format example with current values - example_prices = f"{start:,.6g},{end:,.6g},{limit:,.6g}" + # Build copyable YAML config format + max_open_orders = config.get("max_open_orders", 3) + max_orders_per_batch = config.get("max_orders_per_batch", 1) + order_frequency = config.get("order_frequency", 3) + leverage = config.get("leverage", 1) + side_value = config.get("side", SIDE_LONG) + activation_bounds = config.get("activation_bounds", 0.01) + config_id = config.get("id", "") + + # YAML block for copying + yaml_block = ( + f"connector_name: {connector}\n" + f"trading_pair: {pair}\n" + f"side: {side_value} # 1=LONG, 2=SHORT\n" + f"leverage: {leverage}\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"take_profit: {take_profit} # {take_profit*100:.3f}%\n" + f"min_spread_between_orders: {min_spread} # {min_spread*100:.3f}%\n" + f"min_order_amount_quote: {min_order_amount:.0f}\n" + f"max_open_orders: {max_open_orders}\n" + f"order_frequency: {order_frequency}" + ) + + # Grid analysis info + grid_valid = "OK" if grid.get("valid") else "WARN" + natr_info = f" \\| NATR: {natr*100:.2f}%" if natr else "" - # 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)}`_" + f"*{escape_markdown_v2(pair)}* \\| current: `{current_price:,.6g}`{natr_info}\n\n" + f"```\n{yaml_block}\n```\n\n" + f"Grid: `{grid['num_levels']}` levels \\(↓{grid.get('levels_below_current', 0)} ↑{grid.get('levels_above_current', 0)}\\) " + f"@ `${grid['amount_per_level']:.2f}`/lvl \\[{grid_valid}\\]" ) + # 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` \\(e\\.g\\. `start\\_price=130`\\)_" + # Generate chart and send as photo with caption - if candles: + if candles_list: chart_bytes = generate_candles_chart( - candles, pair, + candles_list, pair, start_price=start, end_price=end, limit_price=limit, - current_price=current_price + current_price=current_price, + side=side ) # Delete old message and send photo with caption + buttons @@ -729,12 +1591,11 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT reply_markup=InlineKeyboardMarkup(keyboard) ) - # 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 - handle photo messages - if query.message.photo: + if getattr(query.message, 'photo', None): try: await query.message.delete() except Exception: @@ -759,7 +1620,7 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] error_msg = format_error_message(f"Error fetching market data: {str(e)}") try: - if query.message.photo: + if getattr(query.message, 'photo', None): try: await query.message.delete() except Exception: @@ -780,58 +1641,9 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT async def handle_gs_accept_prices(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Accept prices and move to take profit step""" - query = update.callback_query - config = get_controller_config(context) - - 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) - - # Validate price ordering based on side - # LONG: limit_price < start_price < end_price - # SHORT: end_price < start_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 (end_price < start_price < limit_price): - validation_error = ( - "Invalid prices for SHORT position\\.\n\n" - "Required: `end < start < limit`\n" - f"Current: `{end_price:,.6g}` < `{start_price:,.6g}` < `{limit_price:,.6g}`" - ) - - 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: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 - - context.user_data["gs_wizard_step"] = "take_profit" - await _show_wizard_take_profit_step(update, context) + """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) async def handle_gs_back_to_prices(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -995,11 +1807,36 @@ async def _show_wizard_review_step(update: Update, context: ContextTypes.DEFAULT ], ] - await query.message.edit_text( - message_text, - parse_mode="MarkdownV2", - reply_markup=InlineKeyboardMarkup(keyboard) - ) + # Handle photo messages - can't edit_text on photos, need to delete and send new + try: + 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) + ) + context.user_data["gs_wizard_message_id"] = msg.message_id + else: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + 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 async def _update_wizard_message_for_review(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -1317,6 +2154,48 @@ async def handle_gs_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> query = update.callback_query config = get_controller_config(context) + # 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) + + 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 (end_price < start_price < limit_price): + validation_error = ( + "Invalid prices for SHORT position\\.\n\n" + "Required: `end < start < limit`\n" + f"Current: `{end_price:,.6g}` < `{start_price:,.6g}` < `{limit_price:,.6g}`" + ) + + 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 + config_id = config.get("id", "") chat_id = query.message.chat_id @@ -1366,8 +2245,9 @@ async def handle_gs_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> 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) + """Go back to prices step (main configuration screen)""" + context.user_data["gs_wizard_step"] = "prices" + await _show_wizard_prices_step(update, context) def _cleanup_wizard_state(context) -> None: @@ -1400,9 +2280,10 @@ async def _background_fetch_market_data(context, config: dict, chat_id: int = No if current_price: context.user_data["gs_current_price"] = current_price - # Fetch candles (5m, 2000 records) - candles = await fetch_candles(client, connector, pair, interval="5m", max_records=2000) + # Fetch candles (1m, 100 records) - consistent with default interval + candles = await fetch_candles(client, connector, pair, interval="1m", max_records=100) context.user_data["gs_candles"] = candles + context.user_data["gs_candles_interval"] = "1m" context.user_data["gs_market_data_ready"] = True logger.info(f"Background fetch complete for {pair}: price={current_price}") @@ -1449,20 +2330,126 @@ async def process_gs_wizard_input(update: Update, context: ContextTypes.DEFAULT_ 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]) + # 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", "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) + + 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) - # 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") + + 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) + else: - raise ValueError("Invalid format") + # 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") elif step == "take_profit": # Parse take profit - interpret as percentage (0.4 = 0.4% = 0.004) @@ -1715,7 +2702,7 @@ async def delete(self): 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""" + """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") @@ -1732,7 +2719,26 @@ async def _update_wizard_message_for_prices_after_edit(update: Update, context: 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") + 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.0002) + 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"] @@ -1744,28 +2750,52 @@ async def _update_wizard_message_for_prices_after_edit(update: Update, context: 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")], ] - # Format example with current values - example_prices = f"{start:,.6g},{end:,.6g},{limit:,.6g}" + # Build copyable YAML config format + 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) + + # YAML block for copying + yaml_block = ( + f"connector_name: {connector}\n" + f"trading_pair: {pair}\n" + f"side: {side_value} # 1=LONG, 2=SHORT\n" + f"leverage: {leverage}\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"take_profit: {take_profit} # {take_profit*100:.3f}%\n" + f"min_spread_between_orders: {min_spread} # {min_spread*100:.3f}%\n" + f"min_order_amount_quote: {min_order_amount:.0f}\n" + f"max_open_orders: {max_open_orders}\n" + f"order_frequency: {order_frequency}" + ) + + # Grid analysis info + grid_valid = "OK" if grid.get("valid") else "WARN" + natr_info = f" \\| NATR: {natr*100:.2f}%" if natr else "" - # 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)}`_" + f"*{escape_markdown_v2(pair)}* \\| current: `{current_price:,.6g}`{natr_info}\n\n" + f"```\n{yaml_block}\n```\n\n" + f"Grid: `{grid['num_levels']}` levels \\(↓{grid.get('levels_below_current', 0)} ↑{grid.get('levels_above_current', 0)}\\) " + f"@ `${grid['amount_per_level']:.2f}`/lvl \\[{grid_valid}\\]" ) + # 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` \\(e\\.g\\. `start\\_price=130`\\)_" + try: # Delete old message (which is a photo) try: @@ -1773,14 +2803,18 @@ async def _update_wizard_message_for_prices_after_edit(update: Update, context: 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: + if candles_list: chart_bytes = generate_candles_chart( - candles, pair, + candles_list, pair, start_price=start, end_price=end, limit_price=limit, - current_price=current_price + current_price=current_price, + side=side ) # Send new photo with updated caption @@ -1825,7 +2859,7 @@ async def handle_gs_edit_price(update: Update, context: ContextTypes.DEFAULT_TYP context.user_data["bots_state"] = "gs_wizard_input" context.user_data["gs_wizard_step"] = field - keyboard = [[InlineKeyboardButton("Cancel", callback_data="bots:gs_accept_prices")]] + keyboard = [[InlineKeyboardButton("Cancel", callback_data="bots:gs_back_to_prices")]] await query.message.edit_text( f"*Edit {escape_markdown_v2(label)}*" + "\n\n" diff --git a/handlers/bots/menu.py b/handlers/bots/menu.py index 977bb4e..c31727c 100644 --- a/handlers/bots/menu.py +++ b/handlers/bots/menu.py @@ -51,9 +51,8 @@ def _build_main_menu_keyboard(bots_dict: Dict[str, Any]) -> InlineKeyboardMarkup InlineKeyboardButton("βž• PMM Mister", callback_data="bots:new_pmm_mister"), ]) - # Action buttons - deploy and configs + # Action buttons - configs keyboard.append([ - InlineKeyboardButton("πŸš€ Deploy", callback_data="bots:deploy_menu"), InlineKeyboardButton("πŸ“ Configs", callback_data="bots:controller_configs"), ]) @@ -377,7 +376,7 @@ async def show_bot_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, bo try: # Check if current message is a photo (from controller detail view) - if query.message.photo: + if getattr(query.message, 'photo', None): # Delete photo message and send new text message try: await query.message.delete() @@ -406,7 +405,7 @@ async def show_bot_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, bo error_message = format_error_message(f"Failed to fetch bot status: {str(e)}") keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]] try: - if query.message.photo: + if getattr(query.message, 'photo', None): try: await query.message.delete() except Exception: @@ -515,7 +514,7 @@ 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 @@ -547,16 +546,9 @@ 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 - message_replaced = False # Track if we've sent a new message (e.g., loading message) try: client = await get_bots_client(chat_id) @@ -569,100 +561,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: - # Show loading message immediately since chart generation takes time - loading_text = f"⏳ *Generating chart for* `{escape_markdown_v2(short_name)}`\\.\\.\\." - try: - if query.message.photo: - # Delete photo and send text message - try: - await query.message.delete() - except Exception: - pass - await query.message.chat.send_message(loading_text, parse_mode="MarkdownV2") - message_replaced = True - else: - await query.message.edit_text(loading_text, parse_mode="MarkdownV2") - except Exception: - pass + except Exception as e: + logger.warning(f"Could not fetch controller config: {e}") - 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) + # 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}" - # 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}") + 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)}`", + ] - except Exception as e: - logger.warning(f"Could not fetch controller config: {e}") + # Add editable config section if available + if ctrl_config and is_grid_strike: + editable_fields = _get_editable_controller_fields(ctrl_config) - # 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)) + # 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 - 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) + # 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: @@ -672,51 +617,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 - - text_content = "\n".join(full_lines) - - # If we replaced the original message (e.g., with loading message), send new message - if message_replaced or query.message.photo: - try: - await query.message.delete() - except Exception: - pass - await query.message.chat.send_message( - text_content, - parse_mode="MarkdownV2", - reply_markup=reply_markup - ) - else: - await query.message.edit_text( - text_content, - parse_mode="MarkdownV2", - reply_markup=reply_markup - ) + 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 async def handle_stop_controller(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -748,7 +673,7 @@ async def handle_stop_controller(update: Update, context: ContextTypes.DEFAULT_T ) # Handle photo messages (from controller detail view with chart) - if query.message.photo: + if getattr(query.message, 'photo', None): try: await query.message.delete() except Exception: @@ -892,12 +817,96 @@ async def handle_quick_start_controller(update: Update, context: ContextTypes.DE # ============================================ 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=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) + + 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: @@ -920,7 +929,7 @@ async def show_controller_edit(update: Update, context: ContextTypes.DEFAULT_TYP # 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 query.message.photo else None + 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 @@ -950,7 +959,7 @@ async def show_controller_edit(update: Update, context: ContextTypes.DEFAULT_TYP text_content = "\n".join(lines) # Handle photo messages (from controller detail view with chart) - if query.message.photo: + if getattr(query.message, 'photo', None): try: await query.message.delete() except Exception: @@ -984,7 +993,6 @@ def _get_editable_controller_fields(ctrl_config: Dict[str, Any]) -> Dict[str, An "max_orders_per_batch": ctrl_config.get("max_orders_per_batch", 1), "min_spread_between_orders": ctrl_config.get("min_spread_between_orders", 0.0002), "take_profit": take_profit, - "manual_kill_switch": ctrl_config.get("manual_kill_switch", False), } @@ -1436,11 +1444,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 # ============================================ From c422308530fdc02414100547231590e56a1aeb54 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Fri, 12 Dec 2025 20:44:01 -0300 Subject: [PATCH 40/51] (feat) improve config menu and width --- handlers/bots/__init__.py | 20 +++ handlers/bots/controller_handlers.py | 122 +++++++++++++----- .../bots/controllers/grid_strike/chart.py | 2 +- 3 files changed, 109 insertions(+), 35 deletions(-) diff --git a/handlers/bots/__init__.py b/handlers/bots/__init__.py index d686307..33bf833 100644 --- a/handlers/bots/__init__.py +++ b/handlers/bots/__init__.py @@ -109,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, @@ -430,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] diff --git a/handlers/bots/controller_handlers.py b/handlers/bots/controller_handlers.py index a646e66..d896824 100644 --- a/handlers/bots/controller_handlers.py +++ b/handlers/bots/controller_handlers.py @@ -174,7 +174,11 @@ async def show_controller_configs_menu(update: Update, context: ContextTypes.DEF context.user_data["configs_page"] = page # 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 pagination @@ -1088,7 +1092,10 @@ async def _show_wizard_pair_step(update: Update, context: ContextTypes.DEFAULT_T if row: keyboard.append(row) - keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:gs_back_to_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) recent_hint = "" if recent_pairs: @@ -1117,7 +1124,10 @@ async def _show_wizard_side_step(update: Update, context: ContextTypes.DEFAULT_T InlineKeyboardButton("πŸ“ˆ LONG", callback_data="bots:gs_side:long"), InlineKeyboardButton("πŸ“‰ SHORT", callback_data="bots:gs_side:short"), ], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:gs_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], ] await query.message.edit_text( @@ -1173,7 +1183,10 @@ async def _show_wizard_leverage_step(update: Update, context: ContextTypes.DEFAU InlineKeyboardButton("50x", callback_data="bots:gs_leverage:50"), InlineKeyboardButton("75x", callback_data="bots:gs_leverage:75"), ], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:gs_back_to_side"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], ] await query.message.edit_text( @@ -1302,7 +1315,10 @@ async def _show_wizard_amount_step(update: Update, context: ContextTypes.DEFAULT InlineKeyboardButton("πŸ’° 2000", callback_data="bots:gs_amount:2000"), InlineKeyboardButton("πŸ’° 5000", callback_data="bots:gs_amount:5000"), ], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:gs_back_to_leverage"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], ] await query.message.edit_text( @@ -1519,7 +1535,10 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT [ InlineKeyboardButton("πŸ’Ύ Save Config", callback_data="bots:gs_save"), ], - [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:gs_back_to_amount"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], ] # Build copyable YAML config format @@ -1541,8 +1560,8 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT f"start_price: {start:.6g}\n" f"end_price: {end:.6g}\n" f"limit_price: {limit:.6g}\n" - f"take_profit: {take_profit} # {take_profit*100:.3f}%\n" - f"min_spread_between_orders: {min_spread} # {min_spread*100:.3f}%\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" f"order_frequency: {order_frequency}" @@ -1550,10 +1569,12 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT # Grid analysis info grid_valid = "OK" if grid.get("valid") else "WARN" - natr_info = f" \\| NATR: {natr*100:.2f}%" if natr else "" + grid_range_pct = grid.get("grid_range_pct", 0) + natr_info = f" \\| NATR: {natr*100:.2f}%".replace(".", "\\.") if natr else "" + range_info = f" \\| range: {grid_range_pct:.2f}%".replace(".", "\\.") config_text = ( - f"*{escape_markdown_v2(pair)}* \\| current: `{current_price:,.6g}`{natr_info}\n\n" + f"*{escape_markdown_v2(pair)}* \\| current: `{current_price:,.6g}`{range_info}{natr_info}\n\n" f"```\n{yaml_block}\n```\n\n" f"Grid: `{grid['num_levels']}` levels \\(↓{grid.get('levels_below_current', 0)} ↑{grid.get('levels_above_current', 0)}\\) " f"@ `${grid['amount_per_level']:.2f}`/lvl \\[{grid_valid}\\]" @@ -1652,6 +1673,47 @@ async def handle_gs_back_to_prices(update: Update, context: ContextTypes.DEFAULT await _show_wizard_prices_step(update, context) +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) + + +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) + + +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) + + +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", "") + + # 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) + + +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) + + 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 @@ -2755,38 +2817,30 @@ async def _update_wizard_message_for_prices_after_edit(update: Update, context: [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], ] - # Build copyable YAML config format + # 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) - - # YAML block for copying - yaml_block = ( - f"connector_name: {connector}\n" - f"trading_pair: {pair}\n" - f"side: {side_value} # 1=LONG, 2=SHORT\n" - f"leverage: {leverage}\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"take_profit: {take_profit} # {take_profit*100:.3f}%\n" - f"min_spread_between_orders: {min_spread} # {min_spread*100:.3f}%\n" - f"min_order_amount_quote: {min_order_amount:.0f}\n" - f"max_open_orders: {max_open_orders}\n" - f"order_frequency: {order_frequency}" - ) + side_str = "LONG" if side_value == SIDE_LONG else "SHORT" # Grid analysis info - grid_valid = "OK" if grid.get("valid") else "WARN" - natr_info = f" \\| NATR: {natr*100:.2f}%" if natr else "" + 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)}* \\| current: `{current_price:,.6g}`{natr_info}\n\n" - f"```\n{yaml_block}\n```\n\n" - f"Grid: `{grid['num_levels']}` levels \\(↓{grid.get('levels_below_current', 0)} ↑{grid.get('levels_above_current', 0)}\\) " - f"@ `${grid['amount_per_level']:.2f}`/lvl \\[{grid_valid}\\]" + 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}` `end_price={end:.6g}`\n" + f"`limit_price={limit:.6g}` `leverage={leverage}`\n" + f"`take_profit={take_profit}` `min_spread={min_spread}`\n" + f"`min_order_amount={min_order_amount:.0f}` `max_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 @@ -2794,7 +2848,7 @@ async def _update_wizard_message_for_prices_after_edit(update: Update, context: 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` \\(e\\.g\\. `start\\_price=130`\\)_" + config_text += "\n\n_Edit: `field=value`_" try: # Delete old message (which is a photo) diff --git a/handlers/bots/controllers/grid_strike/chart.py b/handlers/bots/controllers/grid_strike/chart.py index 0044e64..fbcda5c 100644 --- a/handlers/bots/controllers/grid_strike/chart.py +++ b/handlers/bots/controllers/grid_strike/chart.py @@ -235,7 +235,7 @@ def generate_chart( showgrid=True ), showlegend=False, - width=900, + width=1100, height=500, margin=dict(l=10, r=120, t=50, b=50) # Increased bottom margin for multi-line x-axis labels ) From 598d417f52cf0c1d8df47e4092a3d94abfd0dbd6 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Sun, 14 Dec 2025 23:16:09 -0300 Subject: [PATCH 41/51] (feat) improve formatting style --- handlers/bots/_shared.py | 2 +- handlers/bots/controller_handlers.py | 107 ++++--- .../bots/controllers/grid_strike/chart.py | 261 +++++------------- .../bots/controllers/grid_strike/config.py | 22 +- handlers/bots/menu.py | 23 +- 5 files changed, 144 insertions(+), 271 deletions(-) diff --git a/handlers/bots/_shared.py b/handlers/bots/_shared.py index 5c05569..2442349 100644 --- a/handlers/bots/_shared.py +++ b/handlers/bots/_shared.py @@ -339,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 d896824..a8f265f 100644 --- a/handlers/bots/controller_handlers.py +++ b/handlers/bots/controller_handlers.py @@ -561,7 +561,7 @@ def _get_editable_config_fields(config: dict) -> dict: "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.0002), + "min_spread_between_orders": config.get("min_spread_between_orders", 0.0001), "activation_bounds": config.get("activation_bounds", 0.01), "take_profit": take_profit, } @@ -609,15 +609,9 @@ async def show_cfg_edit_form(update: Update, context: ContextTypes.DEFAULT_TYPE) lines.append(f"`{escape_markdown_v2(config_id)}`") lines.append("") - # Build config text for display - config_lines = [] + # Build config text for display (each line copyable) for key, value in editable_fields.items(): - config_lines.append(f"{key}={value}") - config_text = "\n".join(config_lines) - - lines.append("```") - lines.append(config_text) - lines.append("```") + lines.append(f"`{key}={value}`") lines.append("") lines.append("✏️ _Send `key=value` to update_") @@ -1411,8 +1405,8 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT if current_price: context.user_data["gs_current_price"] = current_price - # Fetch candles (100 records is enough for NATR calculation) - candles = await fetch_candles(client, connector, pair, interval=interval, max_records=100) + # 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 @@ -1471,7 +1465,7 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT config["end_price"] = suggestions["end_price"] config["limit_price"] = suggestions["limit_price"] # Only set these if not already configured - if not config.get("min_spread_between_orders") or config.get("min_spread_between_orders") == 0.0002: + if not config.get("min_spread_between_orders") or config.get("min_spread_between_orders") == 0.0001: config["min_spread_between_orders"] = suggestions["min_spread_between_orders"] if not config.get("triple_barrier_config", {}).get("take_profit") or \ config.get("triple_barrier_config", {}).get("take_profit") == 0.0001: @@ -1488,7 +1482,7 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT start = config.get("start_price") end = config.get("end_price") limit = config.get("limit_price") - min_spread = config.get("min_spread_between_orders", 0.0002) + 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)) @@ -1541,43 +1535,34 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT ], ] - # Build copyable YAML config format + # Get config values max_open_orders = config.get("max_open_orders", 3) - max_orders_per_batch = config.get("max_orders_per_batch", 1) order_frequency = config.get("order_frequency", 3) leverage = config.get("leverage", 1) side_value = config.get("side", SIDE_LONG) - activation_bounds = config.get("activation_bounds", 0.01) - config_id = config.get("id", "") - - # YAML block for copying - yaml_block = ( - f"connector_name: {connector}\n" - f"trading_pair: {pair}\n" - f"side: {side_value} # 1=LONG, 2=SHORT\n" - f"leverage: {leverage}\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"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" - f"order_frequency: {order_frequency}" - ) + side_str_label = "LONG" if side_value == SIDE_LONG else "SHORT" # Grid analysis info - grid_valid = "OK" if grid.get("valid") else "WARN" - grid_range_pct = grid.get("grid_range_pct", 0) - natr_info = f" \\| NATR: {natr*100:.2f}%".replace(".", "\\.") if natr else "" - range_info = f" \\| range: {grid_range_pct:.2f}%".replace(".", "\\.") + 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)}* \\| current: `{current_price:,.6g}`{range_info}{natr_info}\n\n" - f"```\n{yaml_block}\n```\n\n" - f"Grid: `{grid['num_levels']}` levels \\(↓{grid.get('levels_below_current', 0)} ↑{grid.get('levels_above_current', 0)}\\) " - f"@ `${grid['amount_per_level']:.2f}`/lvl \\[{grid_valid}\\]" + 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" ) # Add warnings if any @@ -1585,7 +1570,7 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT 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` \\(e\\.g\\. `start\\_price=130`\\)_" + config_text += "\n\n_Edit: `field=value`_" # Generate chart and send as photo with caption if candles_list: @@ -1815,7 +1800,7 @@ async def _show_wizard_review_step(update: Update, context: ContextTypes.DEFAULT 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) + min_spread = config.get("min_spread_between_orders", 0.0001) # Delete previous chart if exists chart_msg_id = context.user_data.pop("gs_chart_message_id", None) @@ -1926,7 +1911,7 @@ async def _update_wizard_message_for_review(update: Update, context: ContextType 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) + min_spread = config.get("min_spread_between_orders", 0.0001) # Build copyable config block with real YAML field names side_value = config.get("side", SIDE_LONG) @@ -2189,7 +2174,7 @@ async def handle_gs_edit_spread(update: Update, context: ContextTypes.DEFAULT_TY context.user_data["bots_state"] = "gs_wizard_input" context.user_data["gs_wizard_step"] = "edit_spread" - current = config.get("min_spread_between_orders", 0.0002) + current = config.get("min_spread_between_orders", 0.0001) keyboard = [ [InlineKeyboardButton("Cancel", callback_data="bots:gs_review_back")], @@ -2231,11 +2216,11 @@ async def handle_gs_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> f"Current: `{limit_price:,.6g}` < `{start_price:,.6g}` < `{end_price:,.6g}`" ) else: # SHORT - if not (end_price < start_price < limit_price): + if not (start_price < end_price < limit_price): validation_error = ( "Invalid prices for SHORT position\\.\n\n" - "Required: `end < start < limit`\n" - f"Current: `{end_price:,.6g}` < `{start_price:,.6g}` < `{limit_price:,.6g}`" + "Required: `start < end < limit`\n" + f"Current: `{start_price:,.6g}` < `{end_price:,.6g}` < `{limit_price:,.6g}`" ) if validation_error: @@ -2342,8 +2327,8 @@ async def _background_fetch_market_data(context, config: dict, chat_id: int = No if current_price: context.user_data["gs_current_price"] = current_price - # Fetch candles (1m, 100 records) - consistent with default interval - candles = await fetch_candles(client, connector, pair, interval="1m", max_records=100) + # 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 @@ -2438,7 +2423,7 @@ async def process_gs_wizard_input(update: Update, context: ContextTypes.DEFAULT_ val = val / 100 config["min_spread_between_orders"] = val changes_made = True - elif field in ("min_order_amount_quote", "min_order", "min"): + 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"): @@ -2783,7 +2768,7 @@ async def _update_wizard_message_for_prices_after_edit(update: Update, context: 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.0002) + 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") @@ -2834,10 +2819,14 @@ async def _update_wizard_message_for_prices_after_edit(update: Update, context: 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}` `end_price={end:.6g}`\n" - f"`limit_price={limit:.6g}` `leverage={leverage}`\n" - f"`take_profit={take_profit}` `min_spread={min_spread}`\n" - f"`min_order_amount={min_order_amount:.0f}` `max_orders={max_open_orders}`\n\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" @@ -3219,7 +3208,7 @@ async def fetch_and_apply_market_data(update: Update, context: ContextTypes.DEFA set_controller_config(context, config) # Fetch candles for chart - candles = await fetch_candles(client, connector, pair, interval="5m", max_records=50) + candles = await fetch_candles(client, connector, pair, interval="5m", max_records=420) if candles: # Generate and send chart @@ -3427,7 +3416,7 @@ async def process_field_input(update: Update, context: ContextTypes.DEFAULT_TYPE set_controller_config(context, config) # Fetch candles - candles = await fetch_candles(client, connector, pair, interval="5m", max_records=50) + candles = await fetch_candles(client, connector, pair, interval="5m", max_records=420) if candles: chart_bytes = generate_candles_chart( diff --git a/handlers/bots/controllers/grid_strike/chart.py b/handlers/bots/controllers/grid_strike/chart.py index fbcda5c..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,173 +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 - # Use smart date formatting based on zoom level - tickformatstops=[ - dict(dtickrange=[None, 3600000], value="%H:%M"), # < 1 hour between ticks: show time only - dict(dtickrange=[3600000, 86400000], value="%H:%M\n%b %d"), # 1h-1day: show time and date - dict(dtickrange=[86400000, None], value="%b %d"), # > 1 day: show date only - ], - tickangle=0, # Keep labels horizontal - ), - yaxis=dict( - gridcolor=DARK_THEME["grid_color"], - color=DARK_THEME["axis_color"], - side="right", - showgrid=True - ), - showlegend=False, - width=1100, - 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 7d03536..16fef5d 100644 --- a/handlers/bots/controllers/grid_strike/config.py +++ b/handlers/bots/controllers/grid_strike/config.py @@ -42,7 +42,7 @@ "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, @@ -148,8 +148,8 @@ 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", @@ -256,11 +256,11 @@ def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: f"Got: {limit_price:.6g} < {start_price:.6g} < {end_price:.6g}" ) else: - # SHORT: end_price < start_price < limit_price - if not (end_price < start_price < limit_price): + # SHORT: start_price < end_price < limit_price + if not (start_price < end_price < limit_price): return False, ( - f"Invalid prices for SHORT: require end < start < limit. " - f"Got: {end_price:.6g} < {start_price:.6g} < {limit_price:.6g}" + f"Invalid prices for SHORT: require start < end < limit. " + f"Got: {start_price:.6g} < {end_price:.6g} < {limit_price:.6g}" ) return True, None @@ -282,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: @@ -294,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/menu.py b/handlers/bots/menu.py index c31727c..5504a99 100644 --- a/handlers/bots/menu.py +++ b/handlers/bots/menu.py @@ -257,21 +257,17 @@ async def show_bot_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, bo total_realized += realized total_unrealized += unrealized - # Controller section - lines.append("") - lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + # 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 section - lines.append("") - lines.append("*P&L*") + # P&L + Volume in one line (compact) pnl_emoji = "🟒" if pnl >= 0 else "πŸ”΄" - lines.append(f"{pnl_emoji} `{escape_markdown_v2(f'{pnl:+.2f}')}` \\| πŸ’° R: `{escape_markdown_v2(f'{realized:+.2f}')}` \\| πŸ“Š U: `{escape_markdown_v2(f'{unrealized:+.2f}')}`") vol_str = f"{volume/1000:.1f}k" if volume >= 1000 else f"{volume:.0f}" - lines.append(f"πŸ“¦ Vol: `{escape_markdown_v2(vol_str)}`") + lines.append(f"{pnl_emoji} `{escape_markdown_v2(f'{pnl:+.2f}')}` \\(R: `{escape_markdown_v2(f'{realized:+.2f}')}` / U: `{escape_markdown_v2(f'{unrealized:+.2f}')}`\\) πŸ“¦ `{escape_markdown_v2(vol_str)}`") # Open Positions section positions = ctrl_perf.get("positions_summary", []) @@ -342,13 +338,10 @@ async def show_bot_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, bo # Total summary (only if multiple controllers) if len(performance) > 1: - lines.append("") - lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + 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*") - lines.append(f" {pnl_emoji} P&L: `{escape_markdown_v2(f'{total_pnl:+.2f}')}` \\(R: `{escape_markdown_v2(f'{total_realized:+.2f}')}` / U: `{escape_markdown_v2(f'{total_unrealized:+.2f}')}`\\)") - lines.append(f" πŸ“¦ Volume: `{escape_markdown_v2(vol_total)}`") + lines.append(f"*TOTAL* {pnl_emoji} `{escape_markdown_v2(f'{total_pnl:+.2f}')}` \\(R: `{escape_markdown_v2(f'{total_realized:+.2f}')}` / U: `{escape_markdown_v2(f'{total_unrealized:+.2f}')}`\\) πŸ“¦ `{escape_markdown_v2(vol_total)}`") # Error summary at the bottom error_logs = bot_info.get("error_logs", []) @@ -846,7 +839,7 @@ async def show_controller_chart(update: Update, context: ContextTypes.DEFAULT_TY connector_name=connector, trading_pair=pair, interval="1h", - max_records=100 + max_records=420 ) prices = await client.market_data.get_prices( connector_name=connector, @@ -991,7 +984,7 @@ def _get_editable_controller_fields(ctrl_config: Dict[str, Any]) -> Dict[str, An "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.0002), + "min_spread_between_orders": ctrl_config.get("min_spread_between_orders", 0.0001), "take_profit": take_profit, } From 9f5ca83137db9aaf09e768b7428a9e26c35f5011 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Sun, 14 Dec 2025 23:16:19 -0300 Subject: [PATCH 42/51] (feat) use shared viz --- handlers/dex/visualizations.py | 620 ++++++++++++++++++++++----------- 1 file changed, 424 insertions(+), 196 deletions(-) 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) From 12e5cb91cffad9a35ce3de5da0b3700976608192 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Mon, 15 Dec 2025 14:28:32 -0300 Subject: [PATCH 43/51] (feat) improvce gatewya config --- handlers/config/gateway/connectors.py | 11 +++++--- handlers/config/gateway/deployment.py | 28 ++++++++++++------- handlers/config/gateway/menu.py | 4 ++- handlers/config/gateway/networks.py | 8 +++--- handlers/config/gateway/pools.py | 18 ++++++++----- handlers/config/gateway/tokens.py | 20 +++++++++----- handlers/config/gateway/wallets.py | 39 ++++++++++++++++++--------- 7 files changed, 86 insertions(+), 42 deletions(-) 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 5919398..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', []) @@ -113,7 +114,8 @@ async def show_network_details(query, context: ContextTypes.DEFAULT_TYPE, networ 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' @@ -290,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)}\\!" 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 008acc6..ad5459f 100644 --- a/handlers/config/gateway/wallets.py +++ b/handlers/config/gateway/wallets.py @@ -23,7 +23,8 @@ async def show_wallets_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: 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: @@ -35,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: @@ -235,9 +237,11 @@ 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 ) # Base blockchain chains (wallets are at blockchain level, not network level) @@ -278,9 +282,11 @@ async def prompt_add_wallet_chain(query, context: ContextTypes.DEFAULT_TYPE) -> 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 + include_gateway=True, + chat_id=chat_id ) chain_escaped = escape_markdown_v2(chain.title()) @@ -351,9 +357,11 @@ async def show_wallet_details(query, context: ContextTypes.DEFAULT_TYPE, chain: 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 + include_gateway=True, + chat_id=chat_id ) chain_escaped = escape_markdown_v2(chain.title()) @@ -450,9 +458,11 @@ async def toggle_wallet_network(query, context: ContextTypes.DEFAULT_TYPE, walle 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' @@ -491,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: @@ -511,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 = ( @@ -551,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: @@ -578,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()) @@ -625,7 +639,8 @@ 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 from Gateway await client.accounts.remove_gateway_wallet(chain=chain, address=address) @@ -703,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) From baaf30609d2593180c3ca51584c4ad42b9279c9b Mon Sep 17 00:00:00 2001 From: cardosofede Date: Mon, 15 Dec 2025 14:28:43 -0300 Subject: [PATCH 44/51] (feat) remove gateway connectors --- handlers/config/api_keys.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/handlers/config/api_keys.py b/handlers/config/api_keys.py index 1760a98..072dcca 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) @@ -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( @@ -547,7 +553,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( From b31bf685392046f9ff0da04b5fa8fe1a056c4423 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Mon, 15 Dec 2025 14:37:11 -0300 Subject: [PATCH 45/51] (feat) improve grid flow --- handlers/bots/controller_handlers.py | 96 +++++++++++++++++++--------- 1 file changed, 65 insertions(+), 31 deletions(-) diff --git a/handlers/bots/controller_handlers.py b/handlers/bots/controller_handlers.py index a8f265f..8c000e2 100644 --- a/handlers/bots/controller_handlers.py +++ b/handlers/bots/controller_handlers.py @@ -1007,9 +1007,9 @@ async def _show_wizard_connector_step(update: Update, context: ContextTypes.DEFA keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) 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:", + 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) ) @@ -1095,10 +1095,14 @@ async def _show_wizard_pair_step(update: Update, context: ContextTypes.DEFAULT_T 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( - r"*πŸ“ˆ Grid Strike \- New Config*" + "\n\n" - f"*Connector:* `{escape_markdown_v2(connector)}`" + "\n\n" - r"*Step 2/7:* πŸ”— Trading Pair" + "\n\n" + 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) @@ -1124,12 +1128,14 @@ async def _show_wizard_side_step(update: Update, context: ContextTypes.DEFAULT_T ], ] + # 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( - 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:", + 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) ) @@ -1183,13 +1189,11 @@ async def _show_wizard_leverage_step(update: Update, context: ContextTypes.DEFAU ], ] + # Leverage step is only shown for perps (always 6 steps) 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:", + 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) ) @@ -1315,18 +1319,41 @@ async def _show_wizard_amount_step(update: Update, context: ContextTypes.DEFAULT ], ] - 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" + # 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 + + 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"*Step 5/7:* πŸ’° Total Amount \(Quote\)" + "\n\n" - r"Select or type amount in quote currency:", - parse_mode="MarkdownV2", - reply_markup=InlineKeyboardMarkup(keyboard) + r"πŸ’° *Total Amount \(Quote\)*" + "\n\n" + r"Select or type amount:" ) + # 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_wizard_amount(update: Update, context: ContextTypes.DEFAULT_TYPE, amount: float) -> None: """Handle amount selection in wizard""" @@ -1547,8 +1574,13 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT 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 + # 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" @@ -2689,16 +2721,18 @@ async def _update_wizard_message_for_side(update: Update, context: ContextTypes. [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=( - 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:" + 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) From 03a7b3a254ba69170c5e1f30869b3b98fc2c1a90 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Mon, 15 Dec 2025 20:16:14 -0300 Subject: [PATCH 46/51] (feat) improve dex experience --- .../bots/controllers/pmm_mister/__init__.py | 15 + handlers/bots/controllers/pmm_mister/chart.py | 270 ++++++------------ handlers/config/api_keys.py | 63 +++- handlers/dex/_shared.py | 2 +- handlers/dex/liquidity.py | 73 +++-- handlers/dex/pools.py | 103 ++++--- servers.py | 5 +- 7 files changed, 292 insertions(+), 239 deletions(-) diff --git a/handlers/bots/controllers/pmm_mister/__init__.py b/handlers/bots/controllers/pmm_mister/__init__.py index 6073d69..ca252eb 100644 --- a/handlers/bots/controllers/pmm_mister/__init__.py +++ b/handlers/bots/controllers/pmm_mister/__init__.py @@ -29,6 +29,14 @@ 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): @@ -98,4 +106,11 @@ def generate_id( "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 index 06b1279..a9bdcb6 100644 --- a/handlers/bots/controllers/pmm_mister/chart.py +++ b/handlers/bots/controllers/pmm_mister/chart.py @@ -5,34 +5,18 @@ - Buy spread levels (green dashed lines) - Sell spread levels (red dashed lines) - Current price line -- Base percentage target zone indicator +- Take profit zone indicator + +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 parse_spreads -# Dark theme (consistent with grid_strike) -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/buy - "down_color": "#ef4444", # Red for bearish/sell - "line_color": "#3b82f6", # Blue for lines - "target_color": "#f59e0b", # Orange for target -} - - def generate_chart( config: Dict[str, Any], candles_data: List[Dict[str, Any]], @@ -45,6 +29,7 @@ def generate_chart( - 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: @@ -67,8 +52,78 @@ 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 + 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", @@ -80,167 +135,19 @@ def generate_chart( color=DARK_THEME["font_color"] ) ) - else: - # Extract OHLCV data - timestamps = [] - 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: - timestamps.append(dt) - else: - timestamps.append(str(raw_ts)) - - opens.append(candle.get("open", 0)) - highs.append(candle.get("high", 0)) - lows.append(candle.get("low", 0)) - closes.append(candle.get("close", 0)) - - # Use current_price or last close - ref_price = current_price or (closes[-1] if closes else 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 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) - opacity = 0.8 - (i * 0.15) # Fade out for further levels - fig.add_hline( - y=buy_price, - line_dash="dash", - line_color=DARK_THEME["up_color"], - line_width=2, - opacity=max(0.3, opacity), - annotation_text=f"Buy L{i+1}: {buy_price:,.4f} (-{spread*100:.1f}%)", - annotation_position="left", - annotation_font=dict(color=DARK_THEME["up_color"], size=9) - ) - - # 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 = 0.8 - (i * 0.15) - fig.add_hline( - y=sell_price, - line_dash="dash", - line_color=DARK_THEME["down_color"], - line_width=2, - opacity=max(0.3, opacity), - annotation_text=f"Sell L{i+1}: {sell_price:,.4f} (+{spread*100:.1f}%)", - annotation_position="right", - annotation_font=dict(color=DARK_THEME["down_color"], size=9) - ) - - # Add take profit indicator as a shaded zone - if ref_price and take_profit: - tp_up = ref_price * (1 + take_profit) - tp_down = ref_price * (1 - take_profit) - fig.add_hrect( - y0=tp_down, - y1=tp_up, - fillcolor="rgba(245, 158, 11, 0.1)", - line_width=0, - annotation_text=f"TP Zone ({take_profit*100:.2f}%)", - annotation_position="top right", - annotation_font=dict(color=DARK_THEME["target_color"], size=9) - ) - - # Current price line - if current_price: - fig.add_hline( - y=current_price, - line_dash="solid", - line_color=DARK_THEME["target_color"], - line_width=2, - annotation_text=f"Current: {current_price:,.4f}", - annotation_position="left", - annotation_font=dict(color=DARK_THEME["target_color"], size=10) - ) - - # Build title - title_text = f"{trading_pair} - PMM Mister" - - # 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, - tickformat="%b %d\n%H:%M", - tickangle=0, - ), - 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=140, t=50, b=50) - ) + 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( @@ -253,4 +160,5 @@ def generate_preview_chart( 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/config/api_keys.py b/handlers/config/api_keys.py index 072dcca..6fc662e 100644 --- a/handlers/config/api_keys.py +++ b/handlers/config/api_keys.py @@ -542,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: diff --git a/handlers/dex/_shared.py b/handlers/dex/_shared.py index 184cee1..3a93574 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 diff --git a/handlers/dex/liquidity.py b/handlers/dex/liquidity.py index 629c7b2..ac000b6 100644 --- a/handlers/dex/liquidity.py +++ b/handlers/dex/liquidity.py @@ -160,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: @@ -302,9 +303,14 @@ def _format_compact_position_line(pos: dict, token_cache: dict = None, index: in # Get values from pnl_summary (all values are in quote token units) total_pnl_quote = pnl_summary.get('total_pnl_quote', 0) - total_fees_quote = pnl_summary.get('total_fees_value_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 + # 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 @@ -313,31 +319,62 @@ def _format_compact_position_line(pos: dict, token_cache: dict = None, index: in # Add PnL + value + pending fees, converted to USD try: pnl_f = float(total_pnl_quote) if total_pnl_quote else 0 - fees_f = float(total_fees_quote) if total_fees_quote else 0 lp_value_f = float(current_lp_value_quote) if current_lp_value_quote else 0 - # Get quote token price for USD conversion - # All pnl_summary values are in quote token units, so we just need quote price - quote_price = token_prices.get(quote_symbol, 1.0) - - # Convert from quote token to USD + # 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 - fees_usd = fees_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}: lp_value={lp_value_f:.4f} {quote_symbol} @ ${quote_price:.2f} = ${value_usd:.2f}, pnl={pnl_f:.4f} {quote_symbol} = ${pnl_usd:.2f}") + 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 + # 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 fees_usd > 0.01: - parts.append(f"🎁 ${fees_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 @@ -348,7 +385,7 @@ def _format_compact_position_line(pos: dict, token_cache: dict = None, index: in 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) βœ“ [range] | PnL: +$2.88 | 🎁 $1.40 | 1d + Shows: Pair (connector) βœ“ [range] | PnL: +$2.88 | πŸ’° $1.40 | 1d """ token_cache = token_cache or {} token_prices = token_prices or {} @@ -413,7 +450,7 @@ def _format_closed_position_line(pos: dict, token_cache: dict = None, token_pric else: parts.append(f"PnL: -${abs(pnl_usd):.2f}") if fees_usd > 0.01: - parts.append(f"🎁 ${fees_usd:.2f}") + parts.append(f"πŸ’° ${fees_usd:.2f}") if age: parts.append(age) line += "\n " + " | ".join(parts) @@ -863,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) @@ -873,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 diff --git a/handlers/dex/pools.py b/handlers/dex/pools.py index 9743de7..91664db 100644 --- a/handlers/dex/pools.py +++ b/handlers/dex/pools.py @@ -1908,7 +1908,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. @@ -1916,11 +1916,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', '')) @@ -1994,12 +1996,30 @@ def _format_position_detail(pos: dict, token_cache: dict = None, detailed: bool lines.append("") # Separator - # Get quote token price for USD conversion - quote_price = pos.get('quote_token_price', pos.get('quote_price', 1.0)) - try: - quote_price_f = float(quote_price) if quote_price else 1.0 - except (ValueError, TypeError): - quote_price_f = 1.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_f = get_price(quote_symbol, 1.0) + base_price_f = get_price(base_symbol, 0) # Current holdings if base_amount or quote_amount: @@ -2048,21 +2068,29 @@ def _format_position_detail(pos: dict, token_cache: dict = None, detailed: bool except (ValueError, TypeError): pass - # Fees earned - convert to USD - total_fees = pnl_summary.get('total_fees_value_quote') - if total_fees is not None: - try: - fees_val = float(total_fees) * quote_price_f - lines.append(f"🎁 Fees earned: ${fees_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 @@ -2276,8 +2304,11 @@ async def handle_pos_view(update: Update, context: ContextTypes.DEFAULT_TYPE, po token_cache = await get_token_cache_from_gateway() 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) @@ -2396,7 +2427,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( @@ -2404,7 +2435,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.") @@ -2414,14 +2445,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') @@ -2475,9 +2506,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" @@ -2512,9 +2544,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" diff --git a/servers.py b/servers.py index 34335be..6dc854d 100644 --- a/servers.py +++ b/servers.py @@ -299,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 with reasonable timeout + # 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(total=10, connect=5) + timeout=ClientTimeout(total=60, connect=10) ) try: From 34e3febd4c4717b487e182ff019f5dcff64b47eb Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 16 Dec 2025 10:15:15 -0300 Subject: [PATCH 47/51] (feat) simplify grid config --- handlers/bots/controller_handlers.py | 10 ++-------- handlers/bots/controllers/grid_strike/config.py | 6 +++--- handlers/bots/menu.py | 4 ++-- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/handlers/bots/controller_handlers.py b/handlers/bots/controller_handlers.py index 8c000e2..7145e5c 100644 --- a/handlers/bots/controller_handlers.py +++ b/handlers/bots/controller_handlers.py @@ -1491,14 +1491,8 @@ async def _show_wizard_prices_step(update: Update, context: ContextTypes.DEFAULT config["start_price"] = suggestions["start_price"] config["end_price"] = suggestions["end_price"] config["limit_price"] = suggestions["limit_price"] - # Only set these if not already configured - if not config.get("min_spread_between_orders") or config.get("min_spread_between_orders") == 0.0001: - config["min_spread_between_orders"] = suggestions["min_spread_between_orders"] - if not config.get("triple_barrier_config", {}).get("take_profit") or \ - config.get("triple_barrier_config", {}).get("take_profit") == 0.0001: - if "triple_barrier_config" not in config: - config["triple_barrier_config"] = GRID_STRIKE_DEFAULTS["triple_barrier_config"].copy() - config["triple_barrier_config"]["take_profit"] = suggestions["take_profit"] + # 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) diff --git a/handlers/bots/controllers/grid_strike/config.py b/handlers/bots/controllers/grid_strike/config.py index 16fef5d..23a67b1 100644 --- a/handlers/bots/controllers/grid_strike/config.py +++ b/handlers/bots/controllers/grid_strike/config.py @@ -48,7 +48,7 @@ "keep_position": True, "triple_barrier_config": { "open_order_type": 3, - "take_profit": 0.0001, + "take_profit": 0.0005, "take_profit_order_type": 3, }, } @@ -164,8 +164,8 @@ 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", diff --git a/handlers/bots/menu.py b/handlers/bots/menu.py index 5504a99..081f5d7 100644 --- a/handlers/bots/menu.py +++ b/handlers/bots/menu.py @@ -267,7 +267,7 @@ async def show_bot_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, bo # 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} `{escape_markdown_v2(f'{pnl:+.2f}')}` \\(R: `{escape_markdown_v2(f'{realized:+.2f}')}` / U: `{escape_markdown_v2(f'{unrealized:+.2f}')}`\\) πŸ“¦ `{escape_markdown_v2(vol_str)}`") + 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", []) @@ -341,7 +341,7 @@ async def show_bot_detail(update: Update, context: ContextTypes.DEFAULT_TYPE, bo 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} `{escape_markdown_v2(f'{total_pnl:+.2f}')}` \\(R: `{escape_markdown_v2(f'{total_realized:+.2f}')}` / U: `{escape_markdown_v2(f'{total_unrealized:+.2f}')}`\\) πŸ“¦ `{escape_markdown_v2(vol_total)}`") + 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", []) From f69c4895926e5dc927674199761ea4972b88d481 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 16 Dec 2025 14:03:31 -0300 Subject: [PATCH 48/51] (feat) improve grid strike analisis --- .../controllers/grid_strike/grid_analysis.py | 405 ++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 handlers/bots/controllers/grid_strike/grid_analysis.py 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) From 5756705cca13d33efa407dc5bff184c7da74ffa3 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 16 Dec 2025 14:03:41 -0300 Subject: [PATCH 49/51] (feat) refactor pmm mister --- .../controllers/pmm_mister/pmm_analysis.py | 399 ++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 handlers/bots/controllers/pmm_mister/pmm_analysis.py 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, + } From bb087d3a8dcd339be53323639b7c85f340270c74 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 16 Dec 2025 14:04:06 -0300 Subject: [PATCH 50/51] (feat) add cache invalidation --- handlers/config/servers.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/handlers/config/servers.py b/handlers/config/servers.py index 38d1590..69d37d4 100644 --- a/handlers/config/servers.py +++ b/handlers/config/servers.py @@ -261,11 +261,17 @@ async def set_default_server(query, context: ContextTypes.DEFAULT_TYPE, server_n """Set server as default for this chat""" try: from servers import server_manager + from handlers.dex._shared import invalidate_cache chat_id = query.message.chat_id success = server_manager.set_default_server_for_chat(chat_id, server_name) if success: + # 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") + 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: @@ -303,10 +309,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: @@ -873,6 +890,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}") From 8e43dfadbdbd369b68519f3945dccb74a709b6f2 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 16 Dec 2025 20:58:25 -0300 Subject: [PATCH 51/51] (feat) add chat id to get the client --- handlers/cex/orders.py | 6 ++-- handlers/cex/positions.py | 6 ++-- handlers/cex/trade.py | 23 ++++++++----- handlers/config/__init__.py | 4 +-- handlers/config/servers.py | 4 +++ handlers/dex/_shared.py | 18 +++++++++-- handlers/dex/geckoterminal.py | 8 +++-- handlers/dex/menu.py | 9 ++++-- handlers/dex/pool_data.py | 6 ++-- handlers/dex/pools.py | 61 +++++++++++++++++++++++------------ handlers/dex/swap.py | 9 ++++-- 11 files changed, 106 insertions(+), 48 deletions(-) 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/servers.py b/handlers/config/servers.py index 69d37d4..0f66e18 100644 --- a/handlers/config/servers.py +++ b/handlers/config/servers.py @@ -270,6 +270,10 @@ async def set_default_server(query, context: ContextTypes.DEFAULT_TYPE, server_n # 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") diff --git a/handlers/dex/_shared.py b/handlers/dex/_shared.py index 3a93574..63cc7ec 100644 --- a/handlers/dex/_shared.py +++ b/handlers/dex/_shared.py @@ -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 0340197..b54020e 100644 --- a/handlers/dex/geckoterminal.py +++ b/handlers/dex/geckoterminal.py @@ -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: @@ -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: diff --git a/handlers/dex/menu.py b/handlers/dex/menu.py index 429bb77..97c2e6b 100644 --- a/handlers/dex/menu.py +++ b/handlers/dex/menu.py @@ -440,7 +440,8 @@ async def _load_menu_data_background( reply_markup, last_swap, server_name: str = None, - refresh: bool = False + refresh: bool = False, + chat_id: int = None ) -> None: """Background task to load gateway data and update the menu progressively. @@ -449,11 +450,12 @@ async def _load_menu_data_background( 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 # When refresh=True, bypass local cache and tell API to refresh from exchanges @@ -595,8 +597,9 @@ async def show_dex_menu(update: Update, context: ContextTypes.DEFAULT_TYPE, refr 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, refresh=refresh) + _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 diff --git a/handlers/dex/pool_data.py b/handlers/dex/pool_data.py index 2e2e68d..8693634 100644 --- a/handlers/dex/pool_data.py +++ b/handlers/dex/pool_data.py @@ -188,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 @@ -197,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) @@ -215,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 91664db..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") @@ -619,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") @@ -761,7 +768,8 @@ 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 @@ -967,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 @@ -1143,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) @@ -1820,10 +1829,12 @@ async def handle_pool_combined_chart(update: Update, context: ContextTypes.DEFAU # 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: @@ -2170,13 +2181,14 @@ def get_price(symbol, default=0): 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 @@ -2299,9 +2311,10 @@ 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 @@ -2417,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") @@ -2560,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") @@ -2646,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") @@ -3073,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) @@ -3228,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) @@ -3556,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 @@ -3857,7 +3876,8 @@ async def handle_pos_add_confirm(update: Update, context: ContextTypes.DEFAULT_T 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") @@ -4122,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 5734c1d..1120da8 100644 --- a/handlers/dex/swap.py +++ b/handlers/dex/swap.py @@ -966,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") @@ -1083,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") @@ -1183,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")