From 3facb6b78598a83439ca37d7fe6bd36eb674f835 Mon Sep 17 00:00:00 2001 From: pierluigipanariello Date: Fri, 19 Jun 2026 12:23:57 +0200 Subject: [PATCH 1/2] Add custom modifications --- handlers/agents/_shared.py | 1 + handlers/bots/__init__.py | 1018 +- handlers/bots/controller_handlers.py | 14147 ++++++++++++++++++++++++- 3 files changed, 14270 insertions(+), 896 deletions(-) diff --git a/handlers/agents/_shared.py b/handlers/agents/_shared.py index e8519194..737fbcce 100644 --- a/handlers/agents/_shared.py +++ b/handlers/agents/_shared.py @@ -87,6 +87,7 @@ def discover_assistants() -> dict[str, dict[str, str]]: # Sentinel β€” clicking this opens the OpenRouter model picker (handlers/agents/menu.py). # The actual stored agent_llm becomes "openrouter:" once the user picks a model. "openrouter:": {"label": "OpenRouter β€” Pick Model"}, + "openai:deepseek-v4-pro": {"label": "DeepSeek V4 Pro"}, } DEFAULT_AGENT = "claude-code" diff --git a/handlers/bots/__init__.py b/handlers/bots/__init__.py index 3ef6e6b3..29f5c5d7 100644 --- a/handlers/bots/__init__.py +++ b/handlers/bots/__init__.py @@ -1,913 +1,143 @@ """ -Bots module - Bot management and controller configuration - -Supports: -- View active bots status -- Controller configuration (Grid Strike) -- Deploy controllers to backend - -Structure: -- menu.py: Main bots menu and status display -- controllers.py: Controller config management -- _shared.py: Shared utilities and defaults +Controller Registry + +Provides a unified interface for accessing controller type implementations. +Each controller type (grid_strike, pmm, etc.) has its own module with: +- Configuration defaults and field definitions +- Validation logic +- Chart/visualization generation +- ID generation with chronological numbering """ -import logging - -from telegram import Update -from telegram.ext import CallbackQueryHandler, ContextTypes, MessageHandler, filters - -from handlers import clear_all_input_states -from utils.auth import hummingbot_api_required, restricted - -# Archived bots handlers -from .archived import ( - handle_archived_refresh, - handle_generate_report, - show_archived_detail, - show_archived_menu, - show_bot_chart, - show_timeline_chart, -) -from .controller_handlers import ( # Unified configs menu with multi-select; Edit loop; Progressive deploy flow; Streamlined deploy flow; Progressive Grid Strike wizard; PMM Mister wizard; Custom config upload - handle_cfg_branch, - handle_cfg_clear_selection, - handle_cfg_delete_confirm, - handle_cfg_delete_execute, - handle_cfg_deploy, - handle_cfg_edit_cancel, - handle_cfg_edit_field, - handle_cfg_edit_loop, - handle_cfg_edit_next, - handle_cfg_edit_prev, - handle_cfg_edit_save, - handle_cfg_edit_save_all, - handle_cfg_page, - handle_cfg_toggle, - handle_clear_all, - handle_config_file_upload, - handle_configs_page, - handle_cycle_order_type, - handle_deploy_confirm, - handle_deploy_custom_name, - handle_deploy_edit_field, - handle_deploy_prev_field, - handle_deploy_progressive_input, - handle_deploy_set_field, - handle_deploy_skip_field, - handle_deploy_use_default, - handle_edit_config, - handle_execute_deploy, - handle_gs_accept_prices, - handle_gs_back_to_amount, - handle_gs_back_to_connector, - handle_gs_back_to_leverage, - handle_gs_back_to_pair, - handle_gs_back_to_prices, - handle_gs_back_to_side, - handle_gs_edit_act, - handle_gs_edit_batch, - handle_gs_edit_id, - handle_gs_edit_keep, - handle_gs_edit_max_orders, - handle_gs_edit_min_amt, - handle_gs_edit_price, - handle_gs_edit_spread, - handle_gs_edit_tp, - handle_gs_interval_change, - handle_gs_pair_select, - handle_gs_review_back, - handle_gs_save, - handle_gs_wizard_amount, - handle_gs_wizard_connector, - handle_gs_wizard_leverage, - handle_gs_wizard_pair, - handle_gs_wizard_side, - handle_gs_wizard_take_profit, - handle_pmm_adv_setting, - handle_pmm_back, - handle_pmm_edit_advanced, - handle_pmm_edit_field, - handle_pmm_edit_id, - handle_pmm_pair_select, - handle_pmm_review_back, - handle_pmm_save, - handle_pmm_set_field, - handle_pmm_wizard_allocation, - handle_pmm_wizard_amount, - handle_pmm_wizard_connector, - handle_pmm_wizard_leverage, - handle_pmm_wizard_pair, - handle_pmm_wizard_spreads, - handle_pmm_wizard_tp, - handle_pv1_back, - handle_pv1_pair_select, - handle_pv1_review_back, - handle_pv1_save, - handle_pv1_wizard_amount, - handle_pv1_wizard_connector, - handle_pv1_wizard_pair, - handle_pv1_wizard_spreads, - process_pv1_wizard_input, - show_new_pmm_v1_form, - handle_save_config, - handle_select_all, - handle_select_connector, - handle_select_credentials, - handle_select_image, - handle_select_instance_name, - handle_set_field, - handle_toggle_deploy_selection, - handle_toggle_position_mode, - handle_toggle_side, - handle_upload_cancel, - process_cfg_edit_input, - process_deploy_custom_name_input, - process_deploy_field_input, - process_field_input, - process_gs_wizard_input, - process_instance_name_input, - process_pmm_wizard_input, - show_cfg_edit_form, - show_config_form, - show_configs_by_type, - show_configs_list, - show_controller_configs_menu, - show_deploy_config_step, - show_deploy_configure, - show_deploy_form, - show_deploy_menu, - show_new_grid_strike_form, - show_new_pmm_mister_form, - show_type_selector, - show_upload_config_prompt, -) - -# Import submodule handlers -from .menu import ( # Controller chart & edit - handle_back_to_bot, - handle_clone_controller, - handle_close, - handle_confirm_start_controller, - handle_confirm_stop_bot, - handle_confirm_stop_controller, - handle_controller_confirm_set, - handle_controller_set_field, - handle_quick_start_controller, - handle_quick_stop_controller, - handle_refresh, - handle_refresh_bot, - handle_refresh_controller, - handle_start_controller, - handle_stop_bot, - handle_stop_controller, - process_controller_field_input, - show_bot_detail, - show_bot_logs, - show_bots_menu, - show_controller_chart, - show_controller_detail, - show_controller_edit, -) - -logger = logging.getLogger(__name__) +from typing import Dict, List, Optional, Type + +from ._base import BaseController, ControllerField +from .grid_strike import GridStrikeController +from .pmm_mister import PmmMisterController +from .pmm_v1 import PmmV1Controller +from .arbitrage_controller import ArbitrageControllerController +from .dman_v3 import DManV3Controller +from .multi_grid_strike import MultiGridStrikeController +from .xemm_multiple_levels import XEMMMultipleLevelsController +from .macd_bb_v1 import MacdBbV1Controller +from .supertrend_v1 import SuperTrendV1Controller +from .anti_folla_v1 import AntiFollaV1Controller +from .funding_rate_arb import FundingRateArbController +from .delta_neutral_mm import DeltaNeutralMMController +from .bollingrid import BollinGridController +from .quantum_grid_allocator import QuantumGridAllocatorController +from .stat_arb_v2 import StatArbV2Controller +from .lm_multi_pair_dex import LMMultiPairDEXController +# Registry of controller types +_CONTROLLER_REGISTRY: Dict[str, Type[BaseController]] = { + "grid_strike": GridStrikeController, + "pmm_mister": PmmMisterController, + "pmm_v1": PmmV1Controller, + "dman_v3": DManV3Controller, + "arbitrage_controller": ArbitrageControllerController, + "xemm_multiple_levels": XEMMMultipleLevelsController, + "macd_bb_v1": MacdBbV1Controller, + "supertrend_v1": SuperTrendV1Controller, + "anti_folla_v1": AntiFollaV1Controller, + "funding_rate_arb": FundingRateArbController, + "delta_neutral_mm": DeltaNeutralMMController, + "bollingrid": BollinGridController, + "quantum_grid_allocator": QuantumGridAllocatorController, + "stat_arb_v2": LMMultiPairDEXController, + "lm_multi_pair_dex": LMMultiPairDEXController, +} + + +def get_controller(controller_type: str) -> Optional[Type[BaseController]]: + """ + Get a controller class by type. + Args: + controller_type: The controller type identifier (e.g., "grid_strike") -# ============================================ -# MAIN BOTS COMMAND -# ============================================ + Returns: + Controller class or None if not found + """ + return _CONTROLLER_REGISTRY.get(controller_type) -@restricted -@hummingbot_api_required -async def bots_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: +def list_controllers() -> Dict[str, Type[BaseController]]: """ - Handle /bots command - Display bots dashboard + Get all registered controllers. - Usage: - /bots - Show bots dashboard with status and controller options - /bots - Show detailed status for a specific bot + Returns: + Dict mapping controller type to controller class """ - # Clear all pending input states to prevent interference - clear_all_input_states(context) - - # Get the appropriate message object for replies - msg = update.message or ( - update.callback_query.message if update.callback_query else None - ) - if not msg: - logger.error("No message object available for bots_command") - return - - await msg.reply_chat_action("typing") - - # 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(chat_id, context.user_data) - 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") - except Exception as e: - logger.error(f"Error fetching bot status: {e}", exc_info=True) - error_message = format_error_message( - f"Failed to fetch bot status: {str(e)}" - ) - await msg.reply_text(error_message, parse_mode="MarkdownV2") - return - - # Show the interactive menu - await show_bots_menu(update, context) - - -@restricted -@hummingbot_api_required -async def new_bot_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle /new_bot command - Show controller configs menu for creating new bots""" - clear_all_input_states(context) - msg = update.message or ( - update.callback_query.message if update.callback_query else None - ) - if msg: - await msg.reply_chat_action("typing") - await show_controller_configs_menu(update, context) - - -# ============================================ -# CALLBACK HANDLER -# ============================================ - - -@restricted -async def bots_callback_handler( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Handle inline button callbacks - Routes to appropriate handler""" - query = update.callback_query - await query.answer() - - try: - callback_parts = query.data.split(":", 1) - action = callback_parts[1] if len(callback_parts) > 1 else query.data - - # Parse action and any additional parameters - action_parts = action.split(":") - main_action = action_parts[0] - - # Menu navigation - if main_action == "main_menu": - await show_bots_menu(update, context) - - elif main_action == "refresh": - await handle_refresh(update, context) - - elif main_action == "close": - await handle_close(update, context) - - # Controller configs menu - elif main_action == "controller_configs": - await show_controller_configs_menu(update, context) - - elif main_action == "configs_page": - if len(action_parts) > 1: - page = int(action_parts[1]) - await handle_configs_page(update, context, page) - - 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 == "cfg_branch": - await handle_cfg_branch(update, context) - - # Custom config upload - elif main_action == "upload_config": - await show_upload_config_prompt(update, context) - - elif main_action == "upload_cancel": - await handle_upload_cancel(update, context) - - elif main_action == "noop": - pass # Do nothing - used for pagination display button - - elif main_action == "new_grid_strike": - await show_new_grid_strike_form(update, context) - - elif main_action == "new_pmm_mister": - await show_new_pmm_mister_form(update, context) - - elif main_action == "new_pmm_v1": - await show_new_pmm_v1_form(update, context) - - elif main_action == "pv1_connector": - if len(action_parts) > 1: - connector = action_parts[1] - await handle_pv1_wizard_connector(update, context, connector) - - elif main_action == "pv1_pair": - if len(action_parts) > 1: - pair = action_parts[1] - await handle_pv1_wizard_pair(update, context, pair) - - elif main_action == "pv1_pair_select": - if len(action_parts) > 1: - pair = action_parts[1] - await handle_pv1_pair_select(update, context, pair) - - elif main_action == "pv1_amount": - if len(action_parts) > 1: - amount = action_parts[1] - await handle_pv1_wizard_amount(update, context, amount) - - elif main_action == "pv1_spreads": - if len(action_parts) > 1: - spread = action_parts[1] - await handle_pv1_wizard_spreads(update, context, spread) - - elif main_action == "pv1_back": - if len(action_parts) > 1: - target = action_parts[1] - await handle_pv1_back(update, context, target) - - elif main_action == "pv1_save": - await handle_pv1_save(update, context) - - elif main_action == "pv1_review_back": - await handle_pv1_review_back(update, context) - - elif main_action == "edit_config": - if len(action_parts) > 1: - config_index = int(action_parts[1]) - await handle_edit_config(update, context, config_index) - - elif main_action == "edit_config_back": - await show_config_form(update, context) - - elif main_action == "set_field": - if len(action_parts) > 1: - field_name = action_parts[1] - await handle_set_field(update, context, field_name) - - elif main_action == "toggle_side": - await handle_toggle_side(update, context) - - elif main_action == "toggle_position_mode": - await handle_toggle_position_mode(update, context) - - elif main_action == "cycle_order_type": - if len(action_parts) > 1: - order_type_key = action_parts[1] # 'open' or 'tp' - await handle_cycle_order_type(update, context, order_type_key) - - elif main_action == "select_connector": - if len(action_parts) > 1: - connector_name = action_parts[1] - await handle_select_connector(update, context, connector_name) - - elif main_action == "save_config": - await handle_save_config(update, context) - - # Deploy menu - elif main_action == "deploy_menu": - await show_deploy_menu(update, context) - - elif main_action == "toggle_deploy": - if len(action_parts) > 1: - index = int(action_parts[1]) - await handle_toggle_deploy_selection(update, context, index) - - elif main_action == "select_all": - await handle_select_all(update, context) - - elif main_action == "clear_all": - await handle_clear_all(update, context) - - elif main_action == "deploy_configure": - await show_deploy_configure(update, context) - - elif main_action == "deploy_form_back": - await show_deploy_form(update, context) - - elif main_action == "deploy_set": - if len(action_parts) > 1: - field_name = action_parts[1] - await handle_deploy_set_field(update, context, field_name) - - elif main_action == "execute_deploy": - await handle_execute_deploy(update, context) - - # Progressive deploy flow - elif main_action == "deploy_use_default": - if len(action_parts) > 1: - field_name = action_parts[1] - await handle_deploy_use_default(update, context, field_name) - - elif main_action == "deploy_skip_field": - await handle_deploy_skip_field(update, context) - - elif main_action == "deploy_prev_field": - await handle_deploy_prev_field(update, context) - - elif main_action == "deploy_edit": - if len(action_parts) > 1: - field_name = action_parts[1] - await handle_deploy_edit_field(update, context, field_name) - - # Streamlined deploy flow - elif main_action == "deploy_config": - await show_deploy_config_step(update, context) - - elif main_action == "select_creds": - if len(action_parts) > 1: - creds = action_parts[1] - await handle_select_credentials(update, context, creds) - - elif main_action == "select_image": - if len(action_parts) > 1: - # Rejoin parts to preserve colons in image tag (e.g., "hummingbot:development") - image = ":".join(action_parts[1:]) - await handle_select_image(update, context, image) - - elif main_action == "select_name": - if len(action_parts) > 1: - name = action_parts[1] - await handle_select_instance_name(update, context, name) - - elif main_action == "deploy_confirm": - await handle_deploy_confirm(update, context) - - elif main_action == "deploy_custom_name": - await handle_deploy_custom_name(update, context) - - # Progressive Grid Strike wizard - elif main_action == "gs_connector": - if len(action_parts) > 1: - connector = action_parts[1] - await handle_gs_wizard_connector(update, context, connector) + return _CONTROLLER_REGISTRY.copy() - elif main_action == "gs_pair": - if len(action_parts) > 1: - pair = action_parts[1] - await handle_gs_wizard_pair(update, context, pair) - elif main_action == "gs_pair_select": - if len(action_parts) > 1: - pair = action_parts[1] - await handle_gs_pair_select(update, context, pair) +def get_supported_controller_types() -> List[str]: + """Get list of supported controller type identifiers.""" + return list(_CONTROLLER_REGISTRY.keys()) - elif main_action == "gs_side": - if len(action_parts) > 1: - side_str = action_parts[1] - await handle_gs_wizard_side(update, context, side_str) - - elif main_action == "gs_leverage": - if len(action_parts) > 1: - leverage = int(action_parts[1]) - await handle_gs_wizard_leverage(update, context, leverage) - - elif main_action == "gs_amount": - if len(action_parts) > 1: - amount = float(action_parts[1]) - await handle_gs_wizard_amount(update, context, amount) - - elif main_action == "gs_accept_prices": - await handle_gs_accept_prices(update, context) - - 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] - await handle_gs_interval_change(update, context, interval) - - elif main_action == "gs_edit_price": - if len(action_parts) > 1: - price_type = action_parts[1] - await handle_gs_edit_price(update, context, price_type) - - elif main_action == "gs_tp": - if len(action_parts) > 1: - tp = float(action_parts[1]) - await handle_gs_wizard_take_profit(update, context, tp) - - elif main_action == "gs_edit_id": - await handle_gs_edit_id(update, context) - - elif main_action == "gs_edit_keep": - await handle_gs_edit_keep(update, context) - - elif main_action == "gs_edit_tp": - await handle_gs_edit_tp(update, context) - - elif main_action == "gs_edit_act": - await handle_gs_edit_act(update, context) - - elif main_action == "gs_edit_max_orders": - await handle_gs_edit_max_orders(update, context) - - elif main_action == "gs_edit_batch": - await handle_gs_edit_batch(update, context) - - elif main_action == "gs_edit_min_amt": - await handle_gs_edit_min_amt(update, context) - - elif main_action == "gs_edit_spread": - await handle_gs_edit_spread(update, context) - - elif main_action == "gs_save": - await handle_gs_save(update, context) - - 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_pair_select": - if len(action_parts) > 1: - pair = action_parts[1] - await handle_pmm_pair_select(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_amount": - if len(action_parts) > 1: - amount = float(action_parts[1]) - await handle_pmm_wizard_amount(update, context, amount) - - 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_back": - if len(action_parts) > 1: - target = action_parts[1] - await handle_pmm_back(update, context, target) - - elif main_action == "pmm_save": - await handle_pmm_save(update, context) - - elif main_action == "pmm_review_back": - await handle_pmm_review_back(update, context) - - elif main_action == "pmm_edit_id": - await handle_pmm_edit_id(update, context) - - elif main_action == "pmm_edit": - if len(action_parts) > 1: - field = action_parts[1] - await handle_pmm_edit_field(update, context, field) - - elif main_action == "pmm_set": - if len(action_parts) > 2: - field = action_parts[1] - value = action_parts[2] - await handle_pmm_set_field(update, context, field, value) - - elif main_action == "pmm_edit_advanced": - await handle_pmm_edit_advanced(update, context) - - elif main_action == "pmm_adv": - if len(action_parts) > 1: - setting = action_parts[1] - await handle_pmm_adv_setting(update, context, setting) - - # Bot detail - elif main_action == "bot_detail": - if len(action_parts) > 1: - bot_name = action_parts[1] - await show_bot_detail(update, context, bot_name) - - # Controller detail (by index, uses context) - elif main_action == "ctrl_idx": - if len(action_parts) > 1: - idx = int(action_parts[1]) - await show_controller_detail(update, context, idx) - - # Controller chart & edit - elif main_action == "ctrl_chart": - await show_controller_chart(update, context) - - elif main_action == "ctrl_edit": - await show_controller_edit(update, context) - - elif main_action == "ctrl_set": - if len(action_parts) > 1: - field_name = action_parts[1] - await handle_controller_set_field(update, context, field_name) - - elif main_action == "ctrl_confirm_set": - if len(action_parts) > 2: - field_name = action_parts[1] - value = action_parts[2] - await handle_controller_confirm_set(update, context, field_name, value) - - # Stop controller (uses context) - elif main_action == "stop_ctrl": - await handle_stop_controller(update, context) - - elif main_action == "confirm_stop_ctrl": - await handle_confirm_stop_controller(update, context) - - # Start controller (uses context) - elif main_action == "start_ctrl": - await handle_start_controller(update, context) - - elif main_action == "confirm_start_ctrl": - await handle_confirm_start_controller(update, context) - - # Clone controller (PMM Mister only) - elif main_action == "clone_ctrl": - await handle_clone_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) - - elif main_action == "confirm_stop_bot": - await handle_confirm_stop_bot(update, context) - - # View logs (uses context) - elif main_action == "view_logs": - await show_bot_logs(update, context) - - # Navigation - elif main_action == "back_to_bot": - await handle_back_to_bot(update, context) - - 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) - - # Archived bots handlers - elif main_action == "archived": - await show_archived_menu(update, context) - - elif main_action == "archived_page": - if len(action_parts) > 1: - page = int(action_parts[1]) - await show_archived_menu(update, context, page) - - elif main_action == "archived_select": - if len(action_parts) > 1: - db_index = int(action_parts[1]) - await show_archived_detail(update, context, db_index) - - elif main_action == "archived_timeline": - await show_timeline_chart(update, context) - - elif main_action == "archived_chart": - if len(action_parts) > 1: - db_index = int(action_parts[1]) - await show_bot_chart(update, context, db_index) - - elif main_action == "archived_report": - if len(action_parts) > 1: - db_index = int(action_parts[1]) - await handle_generate_report(update, context, db_index) - - elif main_action == "archived_refresh": - await handle_archived_refresh(update, context) - - else: - logger.warning(f"Unknown bots action: {action}") - await query.message.reply_text(f"Unknown action: {action}") - - except Exception as e: - # Ignore "message is not modified" errors - if "not modified" in str(e).lower(): - logger.debug(f"Message not modified (ignored): {e}") - return - - logger.error(f"Error in bots callback handler: {e}", exc_info=True) - from utils.telegram_formatters import format_error_message - - error_message = format_error_message(f"Operation failed: {str(e)}") - try: - await query.message.reply_text(error_message, parse_mode="MarkdownV2") - except Exception as reply_error: - logger.warning(f"Failed to send error message: {reply_error}") - - -# ============================================ -# MESSAGE HANDLER -# ============================================ - - -@restricted -async def bots_message_handler( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Handle user text input - Routes to appropriate processor""" - bots_state = context.user_data.get("bots_state") - - if not bots_state: - return - - user_input = update.message.text.strip() - logger.info(f"Bots message handler - state: {bots_state}, input: {user_input}") - - try: - # Handle controller config field input - if bots_state.startswith("set_field:"): - await process_field_input(update, context, user_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) - elif bots_state.startswith("deploy_set:"): - await process_deploy_field_input(update, context, user_input) - # Handle progressive deploy flow input - elif bots_state == "deploy_progressive": - await handle_deploy_progressive_input(update, context) - # Handle custom instance name input for streamlined deploy - elif bots_state == "deploy_custom_name": - await process_deploy_custom_name_input(update, context, user_input) - # Handle instance name edit in config step - elif bots_state == "deploy_edit_name": - await process_instance_name_input(update, context, user_input) - # Handle Grid Strike wizard input - elif bots_state == "gs_wizard_input": - await process_gs_wizard_input(update, context, user_input) - # Handle PMM Mister wizard input - elif bots_state == "pmm_wizard_input": - await process_pmm_wizard_input(update, context, user_input) - # Handle PMM V1 wizard input - elif bots_state == "pv1_wizard_input": - await process_pv1_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}") - - except Exception as e: - logger.error(f"Error processing bots input: {e}", exc_info=True) - from utils.telegram_formatters import format_error_message - - error_message = format_error_message(f"Failed to process input: {str(e)}") - await update.message.reply_text(error_message, parse_mode="MarkdownV2") - - -# ============================================ -# HANDLER FACTORIES -# ============================================ - - -def get_bots_callback_handler(): - """Get the callback query handler for bots menu""" - return CallbackQueryHandler(bots_callback_handler, pattern="^bots:") - - -def get_bots_message_handler(): - """Returns the message handler""" - return MessageHandler(filters.TEXT & ~filters.COMMAND, bots_message_handler) - - -@restricted -async def bots_document_handler( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Handle document uploads for bots module (e.g., config file uploads)""" - # Only process if we're expecting a config upload - if context.user_data.get("bots_state") == "awaiting_config_upload": - await handle_config_file_upload(update, context) +def get_controller_info() -> Dict[str, Dict[str, str]]: + """ + Get display info for all controllers. -def get_bots_document_handler(): - """Get the document handler for bots module""" - return MessageHandler(filters.Document.ALL, bots_document_handler) + Returns: + Dict mapping type to {name, description} + """ + return { + ctrl_type: { + "name": ctrl.display_name, + "description": ctrl.description, + } + for ctrl_type, ctrl in _CONTROLLER_REGISTRY.items() + } + + +# For backwards compatibility, also export the registry as SUPPORTED_CONTROLLERS +SUPPORTED_CONTROLLERS = { + ctrl_type: { + "name": ctrl.display_name, + "description": ctrl.description, + "defaults": ctrl.get_defaults(), + "fields": { + name: { + "label": field.label, + "type": field.type, + "required": field.required, + "hint": field.hint, + } + for name, field in ctrl.get_fields().items() + }, + "field_order": ctrl.get_field_order(), + } + for ctrl_type, ctrl in _CONTROLLER_REGISTRY.items() +} __all__ = [ - "bots_command", - "bots_callback_handler", - "bots_message_handler", - "bots_document_handler", - "get_bots_callback_handler", - "get_bots_message_handler", - "get_bots_document_handler", + # Registry functions + "get_controller", + "list_controllers", + "get_supported_controller_types", + "get_controller_info", + # Base class + "BaseController", + "ControllerField", + # Controller implementations + "GridStrikeController", + "PmmMisterController", + "PmmV1Controller", + "DManV3Controller", + "ArbitrageControllerController", + "XEMMMultipleLevelsController", + "MacdBbV1Controller", + "SuperTrendV1Controller", + "AntiFollaV1Controller", + "FundingRateArbController", + "DeltaNeutralMMController", + "BollinGridController", + "QuantumGridAllocatorController", + "StatArbV2Controller", + "LMMultiPairDEXController", + # Backwards compatibility + "SUPPORTED_CONTROLLERS", ] diff --git a/handlers/bots/controller_handlers.py b/handlers/bots/controller_handlers.py index d760ba5b..9155d731 100644 --- a/handlers/bots/controller_handlers.py +++ b/handlers/bots/controller_handlers.py @@ -20,7 +20,7 @@ from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.error import BadRequest from telegram.ext import ContextTypes - +from importlib import import_module from handlers.cex._shared import ( get_cex_balances, get_correct_pair_format, @@ -63,10 +63,65 @@ ) from .controllers.pmm_mister import FIELD_ORDER as PMM_FIELD_ORDER from .controllers.pmm_mister import FIELDS as PMM_FIELDS - +from .controllers.lm_multi_pair_dex import LMMultiPairDEXController, generate_id logger = logging.getLogger(__name__) +def _flatten_dict(data: dict, parent_key: str = '', sep: str = '.') -> dict: + """Appiattisce un dizionario annidato in chiavi con dot notation.""" + items = [] + for k, v in data.items(): + new_key = f"{parent_key}{sep}{k}" if parent_key else k + if isinstance(v, dict): + # Esclude alcuni dizionari che non vogliamo appiattire + if k in ('candles_config', 'triple_barrier_config', 'grids'): + items.append((new_key, v)) + continue + items.extend(_flatten_dict(v, new_key, sep=sep).items()) + else: + items.append((new_key, v)) + return dict(items) + + +def _set_nested_value(data: dict, key_path: str, value: any) -> None: + """Imposta un valore in un dizionario annidato usando la dot notation.""" + keys = key_path.split('.') + d = data + for key in keys[:-1]: + if key not in d or not isinstance(d[key], dict): + d[key] = {} + d = d[key] + d[keys[-1]] = value + + +def _get_flat_fields_from_controller(config: dict, controller_type: str) -> dict: + """Tenta di ottenere i campi editabili usando get_flat_fields del controller.""" + try: + module_name = f"handlers.bots.controllers.{controller_type}.config" + config_module = import_module(module_name) + if hasattr(config_module, "get_flat_fields"): + return config_module.get_flat_fields(config) + except (ImportError, AttributeError): + pass + return {} + + +def _apply_flat_updates_to_controller(config: dict, controller_type: str, updates: dict) -> None: + """Applica gli aggiornamenti usando apply_flat_fields se esiste, altrimenti assegna direttamente.""" + try: + module_name = f"handlers.bots.controllers.{controller_type}.config" + config_module = import_module(module_name) + if hasattr(config_module, "apply_flat_fields"): + config_module.apply_flat_fields(config, updates) + return + except (ImportError, AttributeError): + pass + # Fallback: assegnazione diretta (con gestione dot notation) + for key, value in updates.items(): + if '.' in key: + _set_nested_value(config, key, value) + else: + config[key] = value # ============================================ # CONTROLLER CONFIGS MENU # ============================================ @@ -84,6 +139,15 @@ def _get_controller_type_display(controller_name: str) -> tuple[str, str]: "dman_v3": ("DMan V3", "πŸ€–"), "xemm": ("XEMM", "πŸ”„"), "pmm": ("PMM", "πŸ“ˆ"), + "arbitrage_controller": ("Arbitrage", "🎯"), + "macd_bb_v1": ("Macd BB", "πŸ’Ή"), + "supertrend_v1": ("Super Trend", "πŸ“‰"), + "anti_folla_v1": (" Anti Folla", "🎯"), + "funding_rate_arb": (" Funding Rate", "πŸ•”"), + "delta_neutral_mm": (" Delta Neutral", "βš–οΈ"), + "bollingrid": ("Bollinger Grid", "πŸ“Š"), + "quantum_grid_allocator": ("Quantum Grid Allocator", "πŸ“Š"), + "stat_arb_v2": ("Stat Arbitrage", "⚑"), } controller_lower = controller_name.lower() if controller_name else "" for key, (name, emoji) in type_map.items(): @@ -8843,6 +8907,14085 @@ async def _pv1_show_review(context, chat_id, message_id, config): if "Message is not modified" not in str(e): raise +# ============================================ +# MULTI GRID STRIKE WIZARD +# ============================================ +# Steps: connector β†’ pair β†’ grid_type β†’ num_grids β†’ leverage (perp) β†’ amount β†’ review+save +# Prefisso handler: mgs_ + +async def show_new_multi_grid_strike_form( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Start the Multi Grid Strike wizard - Step 1: Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + # Clear cached market data + for key in ["mgs_current_price", "mgs_candles", "mgs_candles_interval", + "mgs_chart_interval", "mgs_natr", "mgs_trading_rules"]: + context.user_data.pop(key, None) + + # Fetch existing configs for sequence numbering + try: + client, _ = await get_bots_client(chat_id, context.user_data) + configs = await client.controllers.list_controller_configs() + context.user_data["controller_configs_list"] = configs + except Exception as e: + logger.warning(f"Could not fetch existing configs for sequencing: {e}") + + # Initialize new config with defaults + init_new_controller_config(context, "multi_grid_strike") + context.user_data["bots_state"] = "mgs_wizard" + context.user_data["mgs_wizard_step"] = "connector_name" + context.user_data["mgs_wizard_message_id"] = query.message.message_id + context.user_data["mgs_wizard_chat_id"] = query.message.chat_id + + await _mgs_show_connector_step(update, context) + + +async def _mgs_show_connector_step( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """MGS Wizard Step 1: Select Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + try: + client, server_name = await get_bots_client(chat_id, context.user_data) + cex_connectors = await get_available_cex_connectors( + context.user_data, client, server_name=server_name + ) + + if not cex_connectors: + keyboard = [ + [InlineKeyboardButton("πŸ”‘ Configure API Keys", callback_data="config_api_keys")], + [InlineKeyboardButton("Β« Back", callback_data="bots:main_menu")], + ] + await query.message.edit_text( + r"*πŸ”² Multi Grid Strike \- New Config*" + "\n\n" + r"⚠️ No CEX connectors available\." + "\n\n" + r"You need to connect API keys for an exchange to deploy strategies\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + keyboard = [] + row = [] + for connector in cex_connectors: + row.append(InlineKeyboardButton( + f"🏦 {connector}", callback_data=f"bots:mgs_connector:{connector}" + )) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + # Escapa TUTTO il testo fisso + message_text = ( + r"\*πŸ”² Multi Grid Strike\*" + "\n\n" + r"Multiple independent grids on the same trading pair, each covering " + r"a different price range\. Ideal for layered market making strategies\." + "\n\n" + r"─────────────────────────" + "\n\n" + r"\*Step 1: Select Exchange\*" + ) + + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + disable_web_page_preview=True, + ) + + except Exception as e: + logger.error(f"MGS connector step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + await query.message.edit_text( + format_error_message(f"Error: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_mgs_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["mgs_wizard_step"] = "trading_pair" + await _mgs_show_pair_step(update, context) + +async def _mgs_show_pair_step( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """MGS Wizard Step 2: Enter Trading Pair""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + + context.user_data["bots_state"] = "mgs_wizard_input" + context.user_data["mgs_wizard_step"] = "trading_pair" + + # Recent pairs from existing configs + 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:mgs_pair:{pair}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + is_perp = connector.endswith("_perpetual") + total_steps = 7 if is_perp else 6 + + # Escapa il connector + escaped_connector = escape_markdown_v2(connector) + + await query.message.edit_text( + rf"*πŸ”² Multi Grid Strike \- Step 2/{total_steps}*" + "\n\n" + f"🏦 `{escaped_connector}`" + "\n\n" + r"πŸ”— *Trading Pair*" + "\n\n" + r"Select a recent pair or type a new one:", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) +async def handle_mgs_wizard_pair( + update: Update, context: ContextTypes.DEFAULT_TYPE, pair: str +) -> None: + """Handle pair selection via button""" + config = get_controller_config(context) + config["trading_pair"] = pair + set_controller_config(context, config) + context.user_data["mgs_wizard_step"] = "grid_type" + await _mgs_show_grid_type_step(update, context) + +async def _mgs_show_grid_type_step( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """MGS Wizard Step 3: Select Strategy Type""" + # Usa i dati salvati + chat_id = context.user_data.get("mgs_wizard_chat_id") + message_id = context.user_data.get("mgs_wizard_message_id") + + if not chat_id or not message_id: + logger.error("MGS: No chat_id or message_id saved for grid_type step") + return + + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + + context.user_data["bots_state"] = "mgs_wizard_input" + context.user_data["mgs_wizard_step"] = "grid_type" + + is_perp = connector.endswith("_perpetual") + total_steps = 7 if is_perp else 6 + current_step = 3 + + escaped_connector = escape_markdown_v2(connector) + escaped_pair = escape_markdown_v2(pair) + + from .controllers.multi_grid_strike.config import GRID_TYPES + + keyboard = [] + for grid_type_key, grid_type_info in GRID_TYPES.items(): + # Crea un unico bottone per strategia con nome e descrizione su due righe + button_text = f"{grid_type_info['label']}\n {grid_type_info['description']}" + keyboard.append([ + InlineKeyboardButton( + button_text, + callback_data=f"bots:mgs_grid_type:{grid_type_key}" + ) + ]) + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + # ========== DEFINISCI message_text ========== + message_text = ( + rf"*πŸ”² Multi Grid Strike \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escaped_connector}` \\| πŸ”— `{escaped_pair}`" + "\n\n" + r"🎯 *Select Strategy Type*" + "\n\n" + r"Choose how your grids will be structured:" + ) + # =========================================== + + await context.bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def _mgs_show_num_grids_step( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """MGS Wizard Step 4: Select Number of Grids""" + query = update.callback_query + + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + grid_type = config.get("grid_strategy_type", "accumulation_distribution") + + from .controllers.multi_grid_strike.config import GRID_TYPES + grid_info = GRID_TYPES.get(grid_type, GRID_TYPES["accumulation_distribution"]) + min_grids = grid_info["min_grids"] + max_grids = grid_info["max_grids"] + default_grids = grid_info["default_grids"] + + context.user_data["bots_state"] = "mgs_wizard_input" + context.user_data["mgs_wizard_step"] = "num_grids" + + # πŸ”§ FIX: Salva i limiti nel contesto per validazione successiva + context.user_data["mgs_min_grids"] = min_grids + context.user_data["mgs_max_grids"] = max_grids + + is_perp = connector.endswith("_perpetual") + total_steps = 7 if is_perp else 6 + current_step = 4 + + escaped_connector = escape_markdown_v2(connector) + escaped_pair = escape_markdown_v2(pair) + escaped_label = escape_markdown_v2(grid_info['label']) + + keyboard = [] + + # Suggest 3 values + suggested = [min_grids, default_grids, max_grids] + if len(set(suggested)) < 3: + suggested = [min_grids, (min_grids + max_grids) // 2, max_grids] + + row = [] + for num in suggested: + row.append(InlineKeyboardButton( + f"{num} grids", callback_data=f"bots:mgs_num_grids:{num}" + )) + keyboard.append(row) + + # Custom option + keyboard.append([ + InlineKeyboardButton("✏️ Custom", callback_data="bots:mgs_num_grids:custom") + ]) + + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_grid_type"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + message_text = ( + rf"*πŸ”² Multi Grid Strike \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escaped_connector}` \\| πŸ”— `{escaped_pair}`" + "\n\n" + f"πŸ“Š *Strategy:* {escaped_label}" + "\n\n" + f"πŸ”’ *Number of Grids*" + "\n" + f"_Min: {min_grids} \\| Max: {max_grids} \\| Default: {default_grids}_" + "\n\n" + r"Select or type a number:" + ) + + # Cancella il messaggio corrente + if query and query.message: + try: + await query.message.delete() + except Exception: + pass + + # Invia nuovo messaggio + new_msg = await context.bot.send_message( + chat_id=update.effective_chat.id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + context.user_data["mgs_wizard_message_id"] = new_msg.message_id + context.user_data["mgs_wizard_chat_id"] = update.effective_chat.id + +async def handle_mgs_grid_type( + update: Update, context: ContextTypes.DEFAULT_TYPE, grid_type: str +) -> None: + """Handle grid type selection""" + config = get_controller_config(context) + config["grid_strategy_type"] = grid_type + set_controller_config(context, config) + + from .controllers.multi_grid_strike.config import GRID_TYPES + grid_info = GRID_TYPES.get(grid_type, GRID_TYPES["accumulation_distribution"]) + default_grids = grid_info["default_grids"] + + context.user_data["mgs_wizard_step"] = "num_grids" + context.user_data["mgs_default_grids"] = default_grids + + await _mgs_show_num_grids_step(update, context) + + +async def handle_mgs_num_grids(update, context, num_grids_str: str) -> None: + """Handle number of grids selection""" + config = get_controller_config(context) + + # πŸ”§ FIX: Recupera i limiti dal contesto + min_grids = context.user_data.get("mgs_min_grids", 2) + max_grids = context.user_data.get("mgs_max_grids", 20) + + if num_grids_str == "custom": + context.user_data["bots_state"] = "mgs_wizard_input" + context.user_data["mgs_wizard_step"] = "num_grids_custom" + context.user_data["mgs_waiting_for_num_grids"] = True + + query = update.callback_query + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + + keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:mgs_back_to_num_grids")]] + await query.message.edit_text( + rf"*πŸ”² Multi Grid Strike*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n\n" + r"*Enter number of grids:*" + "\n" + rf"_Type a number between {min_grids} and {max_grids}_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + num_grids = int(num_grids_str) + + # πŸ”§ FIX: Valida il numero di griglie + if num_grids < min_grids or num_grids > max_grids: + query = update.callback_query + await query.answer(f"Number must be between {min_grids} and {max_grids}", show_alert=True) + return + + config["num_grids"] = num_grids + set_controller_config(context, config) + + connector = config.get("connector_name", "") + if connector.endswith("_perpetual"): + context.user_data["mgs_wizard_step"] = "leverage" + await _mgs_show_leverage_step(update, context) + else: + # Spot: salta leverage + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + set_controller_config(context, config) + context.user_data["mgs_wizard_step"] = "total_amount_quote" + await _mgs_show_amount_step(update, context) + +async def _mgs_show_leverage_step( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """MGS Wizard Step 5 (perp only): Select Leverage""" + query = update.callback_query + + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + + context.user_data["bots_state"] = "mgs_wizard_input" + context.user_data["mgs_wizard_step"] = "leverage" + + keyboard = [ + [ + InlineKeyboardButton("1x", callback_data="bots:mgs_leverage:1"), + InlineKeyboardButton("5x", callback_data="bots:mgs_leverage:5"), + InlineKeyboardButton("10x", callback_data="bots:mgs_leverage:10"), + ], + [ + InlineKeyboardButton("20x", callback_data="bots:mgs_leverage:20"), + InlineKeyboardButton("50x", callback_data="bots:mgs_leverage:50"), + InlineKeyboardButton("75x", callback_data="bots:mgs_leverage:75"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_num_grids"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + escaped_connector = escape_markdown_v2(connector) + escaped_pair = escape_markdown_v2(pair) + + message_text = ( + rf"*πŸ”² Multi Grid Strike \- Step 5/7*" + "\n\n" + f"🏦 `{escaped_connector}` \\| πŸ”— `{escaped_pair}`" + "\n\n" + r"⚑ *Select Leverage*" + "\n" + r"_Or type a value \(e\.g\. 20\)_" + ) + + # ========== INVIA NUOVO MESSAGGIO ========== + # Cancella il messaggio corrente + if query and query.message: + try: + await query.message.delete() + except Exception: + pass + + # Invia nuovo messaggio + new_msg = await context.bot.send_message( + chat_id=update.effective_chat.id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + # Salva il nuovo message_id + context.user_data["mgs_wizard_message_id"] = new_msg.message_id + context.user_data["mgs_wizard_chat_id"] = update.effective_chat.id + + +async def handle_mgs_wizard_leverage(update, context, leverage: int) -> None: + """Handle leverage selection""" + config = get_controller_config(context) + config["leverage"] = leverage + set_controller_config(context, config) + + # Step 6: Position Mode + context.user_data["mgs_wizard_step"] = "position_mode" + await _mgs_show_position_mode_step(update, context) + +async def _mgs_show_position_mode_step( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """MGS Wizard Step: Select Position Mode (only for perpetual)""" + query = update.callback_query + + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + leverage = config.get("leverage", 1) + + context.user_data["bots_state"] = "mgs_wizard_input" + context.user_data["mgs_wizard_step"] = "position_mode" + + is_perp = connector.endswith("_perpetual") + total_steps = 7 if is_perp else 6 + current_step = 6 if is_perp else 5 + + escaped_connector = escape_markdown_v2(connector) + escaped_pair = escape_markdown_v2(pair) + + keyboard = [ + [ + InlineKeyboardButton("πŸ”’ ONEWAY", callback_data="bots:mgs_position_mode:ONEWAY"), + InlineKeyboardButton("πŸ”„ HEDGE", callback_data="bots:mgs_position_mode:HEDGE"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_leverage"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + message_text = ( + rf"*πŸ”² Multi Grid Strike \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escaped_connector}` \\| πŸ”— `{escaped_pair}` \\| ⚑ `{leverage}x`" + "\n\n" + r"🎯 *Position Mode*" + "\n\n" + r"β€’ *ONEWAY*: Can only hold positions in one direction \(long OR short\)" + "\n" + r"β€’ *HEDGE*: Can hold both long and short positions simultaneously" + "\n\n" + r"_Select your position mode:_" + ) + + # ========== INVIA NUOVO MESSAGGIO ========== + # Cancella il messaggio corrente + if query and query.message: + try: + await query.message.delete() + except Exception: + pass + + # Invia nuovo messaggio + new_msg = await context.bot.send_message( + chat_id=update.effective_chat.id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + # Salva il nuovo message_id + context.user_data["mgs_wizard_message_id"] = new_msg.message_id + context.user_data["mgs_wizard_chat_id"] = update.effective_chat.id +async def handle_mgs_back_to_position_mode(update, context) -> None: + """Go back to position mode step""" + context.user_data["mgs_wizard_step"] = "position_mode" + await _mgs_show_position_mode_step(update, context) + +async def handle_mgs_position_mode(update, context, mode: str) -> None: + """Handle position mode selection""" + config = get_controller_config(context) + config["position_mode"] = mode + set_controller_config(context, config) + + # Step 7: Total Amount + context.user_data["mgs_wizard_step"] = "total_amount_quote" + await _mgs_show_amount_step(update, context) + +async def _mgs_show_amount_step( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """MGS Wizard Step: Enter Total Amount""" + # Determina chat_id e message_id + if update.callback_query: + chat_id = update.callback_query.message.chat_id + # Non abbiamo un message_id esistente perchΓ© abbiamo cancellato il vecchio + message_id = None + else: + chat_id = update.effective_chat.id + message_id = None + + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + leverage = config.get("leverage", 1) + grid_type = config.get("grid_strategy_type", "accumulation_distribution") + num_grids = config.get("num_grids", 2) + + context.user_data["bots_state"] = "mgs_wizard_input" + context.user_data["mgs_wizard_step"] = "total_amount_quote" + + is_perp = connector.endswith("_perpetual") + total_steps = 7 if is_perp else 6 + current_step = 7 if is_perp else 5 + + escaped_connector = escape_markdown_v2(connector) + escaped_pair = escape_markdown_v2(pair) + escaped_grid_type = escape_markdown_v2(grid_type) + + # Fetch balance + balance_text = "" + try: + client, _ = await get_bots_client(chat_id, context.user_data) + balances = await get_cex_balances( + context.user_data, client, "master_account", ttl=30 + ) + + # Flexible matching come in Grid Strike + 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", "") + if bal_lower == connector_lower or bal_base == connector_base: + connector_balances = bal_list + break + + if connector_balances: + relevant_balances = [] + quote = pair.split("-")[1] if "-" in pair else "USDT" + for bal in connector_balances: + token = bal.get("token", bal.get("asset", "")) + available = bal.get("units", bal.get("available_balance", bal.get("free", 0))) + if token and token.upper() == quote.upper(): + try: + available_float = float(available) + if available_float > 0: + balance_text = f"\n\nπŸ’° Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`" + break + except (ValueError, TypeError): + continue + except Exception as e: + logger.warning(f"Could not fetch balances for Multi Grid Strike amount step: {e}") + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:mgs_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:mgs_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:mgs_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:mgs_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:mgs_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:mgs_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_leverage" if is_perp else "bots:mgs_back_to_num_grids"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + message_text = ( + rf"*πŸ”² Multi Grid Strike \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escaped_connector}` \\| πŸ”— `{escaped_pair}` \\| ⚑ `{leverage}x`" + "\n" + f"πŸ“Š `{escaped_grid_type}` \\| πŸ”’ `{num_grids}` grids" + balance_text + "\n\n" + r"πŸ’° *Total Amount \(USDT\)*" + "\n" + r"_Select or type an amount:_" + ) + + # ========== INVIA NUOVO MESSAGGIO (non edit) ========== + try: + # Cancella eventuale messaggio precedente + old_msg_id = context.user_data.get("mgs_wizard_message_id") + if old_msg_id: + try: + await context.bot.delete_message(chat_id=chat_id, message_id=old_msg_id) + except Exception: + pass + except Exception: + pass + + # Invia nuovo messaggio + msg = await context.bot.send_message( + chat_id=chat_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["mgs_wizard_message_id"] = msg.message_id + context.user_data["mgs_wizard_chat_id"] = chat_id + + # ============================================= +async def handle_mgs_wizard_amount( + update: Update, context: ContextTypes.DEFAULT_TYPE, amount: float +) -> None: + """Handle amount selection""" + query = update.callback_query + config = get_controller_config(context) + config["total_amount_quote"] = amount + set_controller_config(context, config) + + pair = config.get("trading_pair", "") + await query.message.edit_text( + r"*πŸ”² Multi Grid Strike \- New Config*" + "\n\n" + f"⏳ *Loading market data for* `{escape_markdown_v2(pair)}`\\.\\.\\. " + "\n\n" + r"_Fetching price and generating grids\.\.\._", + parse_mode="MarkdownV2", + ) + + context.user_data["mgs_wizard_step"] = "final" + await _mgs_show_final_step(update, context) + +async def _mgs_show_final_step( + update: Update, context: ContextTypes.DEFAULT_TYPE, interval: str = None +) -> None: + """MGS Final Step: Generate grids, show chart + config summary""" + import html + + if update.callback_query: + query = update.callback_query + msg = query.message + else: + msg = update.message + + chat_id = update.effective_chat.id + config = get_controller_config(context) + + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + total_amount = config.get("total_amount_quote", 1000) + leverage = config.get("leverage", 1) + position_mode = config.get("position_mode", "HEDGE") + grid_type = config.get("grid_strategy_type", "accumulation_distribution") + num_grids = config.get("num_grids", 2) + + logger.info(f"MGS FINAL STEP: grid_type={grid_type}, num_grids={num_grids}, total_amount={total_amount}") + + config["num_grids"] = num_grids + set_controller_config(context, config) + + if interval is None: + interval = context.user_data.get("mgs_chart_interval", "5m") + context.user_data["mgs_chart_interval"] = interval + + current_price = context.user_data.get("mgs_current_price") + candles = context.user_data.get("mgs_candles") + natr = context.user_data.get("mgs_natr") + + try: + cached_interval = context.user_data.get("mgs_candles_interval", "5m") + if not current_price or interval != cached_interval: + try: + await msg.edit_text( + "πŸ”² Multi Grid Strike - New Config\n\n" + f"⏳ Fetching market data for {html.escape(pair)}...", + parse_mode="HTML", + ) + except Exception: + pass + + client, _ = await get_bots_client(chat_id, context.user_data) + current_price = await fetch_current_price(client, connector, pair) + + if current_price: + context.user_data["mgs_current_price"] = current_price + candles = await fetch_candles( + client, connector, pair, interval=interval, max_records=420 + ) + context.user_data["mgs_candles"] = candles + context.user_data["mgs_candles_interval"] = interval + + candles_list = candles.get("data", []) if isinstance(candles, dict) else (candles or []) + if candles_list: + natr = calculate_natr(candles_list, period=14) + context.user_data["mgs_natr"] = natr + + try: + rules = await get_trading_rules(context.user_data, client, connector) + context.user_data["mgs_trading_rules"] = rules.get(pair, {}) + except Exception: + context.user_data["mgs_trading_rules"] = {} + + if not current_price: + keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]] + await msg.edit_text( + f"❌ Error\n\nCould not fetch price for {html.escape(pair)}.", + parse_mode="HTML", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + candles_list = candles.get("data", []) if isinstance(candles, dict) else (candles or []) + if candles_list: + last_close = candles_list[-1].get("close") or candles_list[-1].get("c") + if last_close: + current_price = float(last_close) + context.user_data["mgs_current_price"] = current_price + + min_order_amount = config.get("min_order_amount_quote", 5) + trading_rules = context.user_data.get("mgs_trading_rules", {}) + + from .controllers.multi_grid_strike.grid_analysis import suggest_multi_grid_params + suggestion = suggest_multi_grid_params( + current_price=current_price, + natr=natr or 0.02, + total_amount=total_amount, + min_order_amount=min_order_amount, + num_grids=num_grids, + grid_type=grid_type + ) + + config["grids"] = suggestion["grids"] + set_controller_config(context, config) + + if not config.get("id"): + existing_configs = context.user_data.get("controller_configs_list", []) + from .controllers.multi_grid_strike import generate_id as mgs_generate_id + config["id"] = mgs_generate_id(config, existing_configs) + set_controller_config(context, config) + + min_spread = config.get("min_spread_between_orders", 0.001) + take_profit = config.get("triple_barrier_config", {}).get("take_profit", 0.001) + natr_pct = f"{natr*100:.2f}%" if natr else "N/A" + + # ========== BUILD GRID DISPLAY CON LIMITE DI GRIGLIE VISUALIZZATE ========== + # Per evitare caption troppo lunghe, mostriamo massimo 5 griglie nel messaggio + max_grids_to_show = 5 + total_grids = len(suggestion["grids"]) + + grid_lines = [] + for i, grid in enumerate(suggestion["grids"][:max_grids_to_show]): + side_str = "LONG" if grid["side"] == SIDE_LONG else "SHORT" + grid_lines.append( + f"Grid {i+1}: {html.escape(grid['grid_id'])} ({side_str}, {grid['amount_quote_pct']*100:.0f}%)" + ) + grid_lines.append(f" start={grid['start_price']:.6g}") + grid_lines.append(f" end={grid['end_price']:.6g}") + grid_lines.append(f" limit={grid['limit_price']:.6g}") + + if total_grids > max_grids_to_show: + grid_lines.append(f"...and {total_grids - max_grids_to_show} more grids (edit via Configs menu)") + # ======================================================================== + + context.user_data["bots_state"] = "mgs_wizard_input" + context.user_data["mgs_wizard_step"] = "final" + + interval_options = ["1m", "5m", "15m", "1h", "4h"] + interval_row = [ + InlineKeyboardButton( + f"βœ“ {opt}" if opt == interval else opt, + callback_data=f"bots:mgs_interval:{opt}" + ) + for opt in interval_options + ] + + keyboard = [ + interval_row, + [InlineKeyboardButton("πŸ’Ύ Save Config", callback_data="bots:mgs_save")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_amount"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + is_perp = connector.endswith("_perpetual") + final_step = 7 if is_perp else 6 + + # ========== COSTRUISCI CONFIG_TEXT CON TAG BILANCIATI ========== + # Usa una lista e poi join per evitare problemi di concatenazione + config_text_parts = [ + f"πŸ”² Multi Grid Strike - Step {final_step}/{final_step} (Final)", + "", + f"{html.escape(pair)}", + f"Price: {current_price:,.6g} | NATR: {html.escape(natr_pct)}", + "", + f"connector_name={html.escape(connector)}", + f"trading_pair={html.escape(pair)}", + f"total_amount_quote={total_amount:.0f}", + f"leverage={leverage}", + f"position_mode={html.escape(position_mode)}", + f"min_spread_between_orders={min_spread}", + f"min_order_amount_quote={min_order_amount}", + f"max_open_orders={config.get('max_open_orders', 2)}", + f"take_profit={take_profit}", + f"keep_position={str(config.get('keep_position', False)).lower()}", + "", + ] + config_text_parts.extend(grid_lines) + config_text_parts.extend(["", "Edit individual grids via Configs menu after saving"]) + + config_text = "\n".join(config_text_parts) + # ================================================================ + + # ========== TRONCAMENTO PIΓ™ SICURO ========== + MAX_CAPTION_LEN = 950 + if len(config_text) > MAX_CAPTION_LEN: + truncation_note = "\n\n...truncated due to length limit. Edit via Configs menu." + max_allowed = MAX_CAPTION_LEN - len(truncation_note) + # Cerca l'ultimo newline prima del limite per non troncare a metΓ  riga + last_newline = config_text.rfind('\n', 0, max_allowed) + if last_newline > 0: + config_text = config_text[:last_newline] + truncation_note + else: + config_text = config_text[:max_allowed] + truncation_note + # =========================================== + + # Invia la foto + if candles_list and suggestion["grids"]: + first_grid = suggestion["grids"][0] + chart_bytes = generate_candles_chart( + candles_list, + pair, + start_price=first_grid["start_price"], + end_price=first_grid["end_price"], + limit_price=first_grid["limit_price"], + current_price=current_price, + side=first_grid["side"], + ) + try: + await context.bot.edit_message_media( + chat_id=chat_id, + message_id=msg.message_id, + media=InputMediaPhoto(media=chart_bytes, caption=config_text, parse_mode="HTML"), + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["mgs_wizard_message_id"] = msg.message_id + except Exception as e: + logger.warning(f"edit_message_media fallito: {e}, fallback a delete+send") + try: + await msg.delete() + except Exception: + pass + new_msg = await context.bot.send_photo( + chat_id=chat_id, + photo=chart_bytes, + caption=config_text, + parse_mode="HTML", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["mgs_wizard_message_id"] = new_msg.message_id + context.user_data["mgs_wizard_chat_id"] = chat_id + else: + try: + await msg.edit_text( + text=config_text, + parse_mode="HTML", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["mgs_wizard_message_id"] = msg.message_id + except Exception as e: + try: + await msg.delete() + except Exception: + pass + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=config_text, + parse_mode="HTML", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["mgs_wizard_message_id"] = new_msg.message_id + context.user_data["mgs_wizard_chat_id"] = chat_id + + except Exception as e: + logger.error(f"MGS final step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + try: + await msg.edit_text( + f"Error\n\n{html.escape(str(e))}", + parse_mode="HTML", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + pass +async def handle_mgs_interval_change( + update: Update, context: ContextTypes.DEFAULT_TYPE, interval: str +) -> None: + """Change chart interval""" + query = update.callback_query + context.user_data["mgs_candles"] = None + context.user_data["mgs_candles_interval"] = None + + # Ricrea il final step con un nuovo messaggio + await _mgs_show_final_step(update, context, interval=interval) + + +async def handle_mgs_save( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Save the Multi Grid Strike configuration""" + query = update.callback_query + config = get_controller_config(context) + + # ========== PULISCI I CAMPI NON NECESSARI ========== + # Rimuovi campi interni del wizard + config.pop("grid_strategy_type", None) + config.pop("num_grids", None) + config.pop("candles_config", None) + config.pop("initial_positions", None) + + # Rimuovi 'description' da ogni grid + if "grids" in config: + for grid in config["grids"]: + grid.pop("description", None) + # ========== NUOVO: PULISCI triple_barrier_config (SOLO CAMPI VALIDI) ========== + # Multi Grid Strike supporta SOLO: open_order_type, take_profit, take_profit_order_type + valid_tp_fields = {"open_order_type", "take_profit", "take_profit_order_type"} + + if "triple_barrier_config" in config: + tp_config = config["triple_barrier_config"] + if isinstance(tp_config, dict): + # Filtra solo i campi validi + cleaned_tp = { + k: v for k, v in tp_config.items() + if k in valid_tp_fields and v is not None + } + if cleaned_tp: + config["triple_barrier_config"] = cleaned_tp + else: + config.pop("triple_barrier_config", None) + else: + config.pop("triple_barrier_config", None) + + # ========== NUOVO: RIMUOVI CAMPI NON STANDARD (ereditati da altri wizard) ========== + invalid_fields = [ + "candles_connector", + "candles_trading_pair", + "interval", + "bb_length", + "bb_std", + "bb_long_threshold", + "bb_short_threshold", + "macd_fast", + "macd_slow", + "macd_signal", + "dca_spreads", + "dca_amounts_pct", + "dynamic_order_spread", + "dynamic_target", + "cooldown_time", + "max_executors_per_side", + "stop_loss", + "time_limit", + "trailing_stop", + "trailing_stop_activation", + "trailing_stop_delta", + ] + for field in invalid_fields: + config.pop(field, None) + + # ========== GARANTISCI CHE I CAMPI OBBLIGATORI SIANO PRESENTI ========== + # Se manca triple_barrier_config ma c'Γ¨ take_profit da qualche parte + if "take_profit" in config and "triple_barrier_config" not in config: + config["triple_barrier_config"] = { + "take_profit": config.pop("take_profit") + } + + # Assicurati che open_order_type e take_profit_order_type abbiano valori validi + if "triple_barrier_config" in config: + if "open_order_type" not in config["triple_barrier_config"]: + config["triple_barrier_config"]["open_order_type"] = ORDER_TYPE_LIMIT_MAKER + if "take_profit_order_type" not in config["triple_barrier_config"]: + config["triple_barrier_config"]["take_profit_order_type"] = ORDER_TYPE_LIMIT_MAKER + + # =================================================== + + config_id = config.get("id", "") + chat_id = query.message.chat_id + + try: + await query.message.delete() + except Exception: + pass + + status_msg = await context.bot.send_message( + chat_id=chat_id, + text="Saving configuration `" + escape_markdown_v2(config_id) + "`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + await client.controllers.create_or_update_controller_config(config_id, config) + + # Cleanup wizard state + for key in ["mgs_wizard_step", "mgs_wizard_message_id", "mgs_wizard_chat_id", + "mgs_current_price", "mgs_candles", "mgs_candles_interval", + "mgs_chart_interval", "mgs_natr", "mgs_trading_rules", + "mgs_default_grids", "mgs_waiting_for_num_grids"]: + context.user_data.pop(key, None) + context.user_data.pop("bots_state", None) + + keyboard = [ + [InlineKeyboardButton("Create Another", callback_data="bots:new_multi_grid_strike")], + [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")], + ] + saved_msg = ( + "*Config Saved\\!*\n\n" + "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.\n" + "Use πŸ“‹ Configs menu to edit individual grids or parameters\\." + ) + await status_msg.edit_text( + saved_msg, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"MGS save error: {e}", exc_info=True) + keyboard = [ + [InlineKeyboardButton("Try Again", callback_data="bots:mgs_save")], + [InlineKeyboardButton("Back", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + format_error_message(f"Failed to save: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +# ============================================ +# MGS BACK HANDLERS +# ============================================ + +async def handle_mgs_back_to_connector( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + context.user_data["mgs_wizard_step"] = "connector_name" + await _mgs_show_connector_step(update, context) + + +async def handle_mgs_back_to_pair( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + context.user_data["mgs_wizard_step"] = "trading_pair" + await _mgs_show_pair_step(update, context) + + +async def handle_mgs_back_to_grid_type( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + context.user_data["mgs_wizard_step"] = "grid_type" + await _mgs_show_grid_type_step(update, context) + +async def handle_mgs_back_to_leverage(update, context) -> None: + config = get_controller_config(context) + if config.get("connector_name", "").endswith("_perpetual"): + context.user_data["mgs_wizard_step"] = "leverage" + + # Cancella messaggio corrente + query = update.callback_query + try: + await query.message.delete() + except Exception: + pass + + await _mgs_show_leverage_step(update, context) + else: + await handle_mgs_back_to_num_grids(update, context) + +async def handle_mgs_back_to_num_grids(update, context) -> None: + """Go back to num_grids step""" + query = update.callback_query + + # Cancella il messaggio corrente + if query and query.message: + try: + await query.message.delete() + except Exception: + pass + + context.user_data["mgs_wizard_step"] = "num_grids" + await _mgs_show_num_grids_step(update, context) + +async def handle_mgs_back_to_amount( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Go back to amount step""" + query = update.callback_query + + # Cancella il messaggio corrente + if query and query.message: + try: + await query.message.delete() + except Exception: + pass + + context.user_data["mgs_wizard_step"] = "total_amount_quote" + context.user_data.pop("mgs_current_price", None) + context.user_data.pop("mgs_candles", None) + + await _mgs_show_amount_step(update, context) + + +# ============================================ +# MGS PAIR SELECTION HANDLER +# ============================================ + +async def handle_mgs_pair_select( + update: Update, context: ContextTypes.DEFAULT_TYPE, pair: str +) -> None: + """Handle pair selection via button""" + await handle_mgs_wizard_pair(update, context, pair) + + +# ============================================ +# MGS TEXT INPUT PROCESSOR +# ============================================ +async def process_mgs_wizard_input( + update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str +) -> None: + """Process text input during MGS wizard""" + step = context.user_data.get("mgs_wizard_step") + logger.info(f"πŸ” MGS DEBUG: step={step}, input={user_input}, bots_state={context.user_data.get('bots_state')}") + chat_id = update.effective_chat.id + config = get_controller_config(context) + message_id = context.user_data.get("mgs_wizard_message_id") + wizard_chat_id = context.user_data.get("mgs_wizard_chat_id", chat_id) + + try: + try: + await update.message.delete() + except Exception: + pass + + # ========== GESTISCI INPUT MANUALE PER TRADING PAIR ========== + if step == "trading_pair": + pair = user_input.upper().strip().replace("/", "-").replace("_", "-") + connector = config.get("connector_name", "") + + # Validazione base: deve contenere un trattino + if "-" not in pair: + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_connector")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + ] + error_text = ( + r"*πŸ”² Multi Grid Strike \- Step 2*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}`\n\n" + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n" + r"Type the pair again \(e\.g\. `BTC\-USDT`\):" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + else: + msg = await context.bot.send_message( + chat_id=chat_id, + text=error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["mgs_wizard_message_id"] = msg.message_id + return + + # Valida il trading pair sull'exchange + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = ( + await validate_trading_pair(context.user_data, client, connector, pair) + ) + + if not is_valid: + # Mostra suggerimenti se disponibili + keyboard = [] + for sugg in suggestions[:4]: + keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:mgs_pair:{sugg}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_connector")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + error_text = ( + r"*πŸ”² Multi Grid Strike \- Step 2*" + "\n\n" + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n" + r"*Did you mean?*" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + else: + msg = await context.bot.send_message( + chat_id=chat_id, + text=error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["mgs_wizard_message_id"] = msg.message_id + return + + if correct_pair: + pair = correct_pair + + config["trading_pair"] = pair + for key in ["mgs_current_price", "mgs_candles", "mgs_candles_interval", "mgs_natr"]: + context.user_data.pop(key, None) + set_controller_config(context, config) + # ========== DEBUG ========== + logger.info(f"πŸ” MGS: pair set to {pair}, now calling _mgs_show_grid_type_step") + # =========================== + # Vai allo step successivo (grid_type) + context.user_data["mgs_wizard_step"] = "grid_type" + await _mgs_show_grid_type_step(update, context) + + # ========== GESTISCI INPUT PER NUM_GRIDS_CUSTOM ========== + elif step == "num_grids_custom": + try: + num_grids = int(user_input.strip()) + + # πŸ”§ FIX: Recupera i limiti dal contesto + min_grids = context.user_data.get("mgs_min_grids", 2) + max_grids = context.user_data.get("mgs_max_grids", 20) + + if num_grids < min_grids or num_grids > max_grids: + raise ValueError(f"Number must be between {min_grids} and {max_grids}") + + config["num_grids"] = num_grids + set_controller_config(context, config) + context.user_data.pop("mgs_waiting_for_num_grids", None) + + connector = config.get("connector_name", "") + if connector.endswith("_perpetual"): + context.user_data["mgs_wizard_step"] = "leverage" + await _mgs_show_leverage_step(update, context) + else: + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + set_controller_config(context, config) + context.user_data["mgs_wizard_step"] = "total_amount_quote" + await _mgs_show_amount_step(update, context) + except ValueError as e: + keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:mgs_back_to_num_grids")]] + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ”² Multi Grid Strike*" + "\n\n" + f"❌ *Invalid number:* {escape_markdown_v2(str(e))}" + "\n\n" + rf"*Enter number of grids ({min_grids}-{max_grids}):*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + elif step == "leverage": + try: + val = int(float(user_input.strip().lower().replace("x", ""))) + config["leverage"] = val + set_controller_config(context, config) + context.user_data["mgs_wizard_step"] = "total_amount_quote" + await _mgs_show_amount_step(update, context) + except ValueError: + keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:mgs_back_to_leverage")]] + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=r"*πŸ”² Multi Grid Strike*" + "\n\n" + r"❌ *Invalid leverage*" + "\n\n" + r"Enter a positive number \(e\.g\. 20\):", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + elif step == "total_amount_quote": + try: + amount = float(user_input.strip().replace("$", "").replace(",", "")) + config["total_amount_quote"] = amount + set_controller_config(context, config) + context.user_data["mgs_wizard_step"] = "final" + # Show loading + tmp = await context.bot.send_message( + chat_id=wizard_chat_id, + text=r"*πŸ”² Multi Grid Strike*" + "\n\n" + f"⏳ Loading market data for `{escape_markdown_v2(config.get('trading_pair', ''))}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + context.user_data["mgs_wizard_message_id"] = tmp.message_id + await _mgs_show_final_step(update, context) + except ValueError: + keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:mgs_back_to_amount")]] + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=r"*πŸ”² Multi Grid Strike*" + "\n\n" + r"❌ *Invalid amount*" + "\n\n" + r"Enter a positive number \(e\.g\. 500\):", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + elif step == "final": + # Handle field=value edits + if "=" in user_input: + for line in user_input.strip().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() + try: + if field in ("min_spread_between_orders", "min_spread"): + val = float(value.replace("%", "")) + config["min_spread_between_orders"] = val / 100 if val > 1 else val + elif field in ("take_profit", "tp"): + val = float(value.replace("%", "")) + if "triple_barrier_config" not in config: + config["triple_barrier_config"] = {} + config["triple_barrier_config"]["take_profit"] = val / 100 if val > 1 else val + elif field in ("total_amount_quote", "amount"): + config["total_amount_quote"] = float(value) + elif field == "leverage": + config["leverage"] = int(float(value)) + elif field == "max_open_orders": + config["max_open_orders"] = int(float(value)) + elif field == "min_order_amount_quote": + config["min_order_amount_quote"] = float(value) + elif field == "keep_position": + config["keep_position"] = value.lower() in ("true", "yes", "1") + elif field == "position_mode": + config["position_mode"] = value.upper() + except Exception: + pass + set_controller_config(context, config) + # Refresh the final step + await _mgs_show_final_step(update, context) + + except Exception as e: + logger.error(f"MGS wizard input error: {e}", exc_info=True) + +# ============================================ +# GENERIC SAVE HANDLER (for wizard fallback) +# ============================================ +async def _show_new_generic_form( + update, context, controller_type: str +) -> None: + """Generic handler for new bot configs - saves with defaults.""" + from .controllers import get_controller + query = update.callback_query + chat_id = update.effective_chat.id + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + configs = await client.controllers.list_controller_configs() + context.user_data["controller_configs_list"] = configs + except Exception as e: + logger.warning(f"Could not fetch existing configs for sequencing: {e}") + configs = [] + + ctrl_cls = get_controller(controller_type) + config = ctrl_cls.get_defaults() if ctrl_cls else {} + config_id = ctrl_cls.generate_id(config, configs) if ctrl_cls else f"001_{controller_type}" + config["id"] = config_id + + lines = [ + f"*πŸ†• New {escape_markdown_v2(ctrl_cls.display_name if ctrl_cls else controller_type)}*", + "", + f"`{escape_markdown_v2(config_id)}`", + "", + "_Config created with default values\\._", + "_Use πŸ“‹ Configs menu to edit fields\\._", + "", + ] + for key, value in config.items(): + if key in ("controller_name", "controller_type"): + continue + lines.append(f"`{escape_markdown_v2(str(key))}={escape_markdown_v2(str(value))}`") + + keyboard = [ + [InlineKeyboardButton("πŸ’Ύ Save with defaults", callback_data=f"bots:generic_save:{controller_type}")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")], + ] + + set_controller_config(context, config) + context.user_data["generic_pending_id"] = config_id + + try: + await query.message.edit_text( + "\n".join(lines), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"Error showing generic form: {e}", exc_info=True) + + +async def handle_generic_save( + update, context, controller_type: str +) -> None: + """Save a generic bot config with default values.""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + config_id = config.get("id", context.user_data.get("generic_pending_id", f"001_{controller_type}")) + + try: + await query.message.delete() + except Exception: + pass + + status_msg = await context.bot.send_message( + chat_id=chat_id, + text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + await client.controllers.create_or_update_controller_config(config_id, config) + + keyboard = [ + [InlineKeyboardButton("πŸ“‹ Back to Configs", callback_data="bots:controller_configs")], + ] + saved_text = ( + "*βœ… Config Saved\\!*\n\n" + + "`" + escape_markdown_v2(config_id) + "` saved with default values\\.\n" + + "Open πŸ“‹ Configs to edit the fields\\." + ) + await status_msg.edit_text( + saved_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"Error saving generic config: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:controller_configs")]] + await status_msg.edit_text( + format_error_message(f"Failed to save: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +# ============================================ +# DMAN V3 WIZARD +# ============================================ +# Steps: connector β†’ pair β†’ (leverage) β†’ amount β†’ interval+chart β†’ save +# Prefisso handler: dman_ + +async def show_new_dman_v3_form( + update, context +) -> None: + """Start the DMan V3 wizard - Step 1: Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + # ========== PULISCI STATI DI ALTRI WIZARD ========== + # Rimuovi tutti i message_id e chat_id di altri wizard + for key in list(context.user_data.keys()): + if key.endswith("_wizard_message_id") or key.endswith("_wizard_chat_id"): + context.user_data.pop(key, None) + if key.endswith("_wizard_step"): + context.user_data.pop(key, None) + # =================================================== + + # Clear cached data + for key in ["dman_current_price", "dman_candles", "dman_candles_interval", + "dman_chart_interval", "dman_trading_rules"]: + context.user_data.pop(key, None) + + # Fetch existing configs for sequence numbering + try: + client, _ = await get_bots_client(chat_id, context.user_data) + 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}") + + init_new_controller_config(context, "dman_v3") + context.user_data["bots_state"] = "dman_wizard" + context.user_data["dman_wizard_step"] = "connector_name" + context.user_data["dman_wizard_message_id"] = query.message.message_id + context.user_data["dman_wizard_chat_id"] = query.message.chat_id + + await _dman_show_connector_step(update, context) + + +async def _dman_show_connector_step(update, context) -> None: + """DMan Step 1: Select Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + try: + client, server_name = await get_bots_client(chat_id, context.user_data) + cex_connectors = await get_available_cex_connectors( + context.user_data, client, server_name=server_name + ) + + if not cex_connectors: + keyboard = [ + [InlineKeyboardButton("πŸ”‘ Configure API Keys", callback_data="config_api_keys")], + [InlineKeyboardButton("Β« Back", callback_data="bots:main_menu")], + ] + await query.message.edit_text( + r"*πŸ“‰ DMan V3 \- New Config*" + "\n\n" + r"⚠️ No CEX connectors available\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + keyboard = [] + row = [] + for connector in cex_connectors: + row.append(InlineKeyboardButton( + f"🏦 {connector}", callback_data=f"bots:dman_connector:{connector}" + )) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + await query.message.edit_text( + r"*πŸ“‰ DMan V3*" + "\n\n" + r"Mean reversion strategy using Bollinger Bands to detect overbought/oversold " + r"conditions, then enters with DCA orders at multiple levels\." + "\n\n" + r"─────────────────────────" + "\n\n" + r"*Step 1: Select Exchange*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + disable_web_page_preview=True, + ) + + except Exception as e: + logger.error(f"DMan connector step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + await query.message.edit_text( + format_error_message(f"Error: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_dman_wizard_connector(update, context, connector: str) -> None: + """Handle connector selection""" + config = get_controller_config(context) + config["connector_name"] = connector + # Auto-set candles connector = same exchange + config["candles_connector"] = connector + set_controller_config(context, config) + context.user_data["dman_wizard_step"] = "trading_pair" + await _dman_show_pair_step(update, context) + +async def _dman_show_pair_step(update, context) -> None: + """DMan Step 2: Select Trading Pair""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + + context.user_data["bots_state"] = "dman_wizard_input" + context.user_data["dman_wizard_step"] = "trading_pair" + + # Recent pairs from existing configs + 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:dman_pair:{pair}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 6 if is_perp else 4 + current_step = 2 + message_text = ( + rf"*πŸ“‰ DMan V3 \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n" + r"πŸ”— *Trading Pair*" + "\n\n" + r"Select a recent pair or type a new one:" + ) + + # ========== SALVA MESSAGE_ID ========== + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["dman_wizard_message_id"] = query.message.message_id + except Exception: + try: + await query.message.delete() + except Exception: + pass + new_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["dman_wizard_message_id"] = new_msg.message_id + context.user_data["dman_wizard_chat_id"] = query.message.chat_id + +async def handle_dman_wizard_pair(update, context, pair: str) -> None: + """Handle pair selection""" + config = get_controller_config(context) + config["trading_pair"] = pair + config["candles_trading_pair"] = pair + set_controller_config(context, config) + + connector = config.get("connector_name", "").lower() + is_perp = "_perpetual" in connector or "_margin" in connector + + if is_perp: + # Step: Leverage + context.user_data["dman_wizard_step"] = "leverage" + await _dman_show_leverage_step(update, context) + else: + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" # spot β†’ ONEWAY obbligatorio + set_controller_config(context, config) + context.user_data["dman_wizard_step"] = "total_amount_quote" + await _dman_show_amount_step(update, context) + +async def _dman_show_leverage_step(update, context) -> None: + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + + context.user_data["bots_state"] = "dman_wizard_input" + context.user_data["dman_wizard_step"] = "leverage" + + keyboard = [ + [ + InlineKeyboardButton("1x", callback_data="bots:dman_leverage:1"), + InlineKeyboardButton("5x", callback_data="bots:dman_leverage:5"), + InlineKeyboardButton("10x", callback_data="bots:dman_leverage:10"), + ], + [ + InlineKeyboardButton("20x", callback_data="bots:dman_leverage:20"), + InlineKeyboardButton("50x", callback_data="bots:dman_leverage:50"), + InlineKeyboardButton("75x", callback_data="bots:dman_leverage:75"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + # ========== SALVA message_id PER ERROR HANDLING ========== + context.user_data["dman_wizard_message_id"] = query.message.message_id + context.user_data["dman_wizard_chat_id"] = query.message.chat_id + # ======================================================== + + await query.message.edit_text( + r"*πŸ“‰ DMan V3 \- Step 3/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n\n" + r"⚑ *Select Leverage*" + "\n" + r"_Or type a value \(e\.g\. 20\)_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_dman_wizard_leverage(update, context, leverage: int) -> None: + config = get_controller_config(context) + config["leverage"] = leverage + set_controller_config(context, config) + context.user_data["dman_wizard_step"] = "position_mode" + await _dman_show_position_mode_step(update, context) + +async def _dman_show_position_mode_step(update, context) -> None: + """DMan Step 4 (derivati only): HEDGE vs ONEWAY""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + leverage = config.get("leverage", 1) + + context.user_data["bots_state"] = "dman_wizard_input" + context.user_data["dman_wizard_step"] = "position_mode" + + keyboard = [ + [ + InlineKeyboardButton("πŸ”€ HEDGE βœ… recommended", callback_data="bots:dman_position_mode:HEDGE"), + InlineKeyboardButton("➑️ ONEWAY", callback_data="bots:dman_position_mode:ONEWAY"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_leverage"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + # CORREZIONE: escape di tutti i caratteri speciali MarkdownV2 + # I caratteri speciali sono: _ * [ ] ( ) ~ ` > # + - = | { } . ! + escaped_connector = escape_markdown_v2(connector) + escaped_pair = escape_markdown_v2(pair) + + await query.message.edit_text( + f"*πŸ“‰ DMan V3 \\- Step 4/6*\n\n" + f"🏦 `{escaped_connector}` \\| πŸ”— `{escaped_pair}` \\| ⚑ `{leverage}x`\n\n" + r"πŸ“ *Position Mode*" + "\n\n" + r"β€’ *HEDGE*: Can hold both long and short positions simultaneously" + "\n" + r"β€’ *ONEWAY*: Can only hold positions in one direction \(long OR short\)", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) +async def handle_dman_position_mode(update, context, position_mode: str) -> None: + config = get_controller_config(context) + config["position_mode"] = position_mode + set_controller_config(context, config) + context.user_data["dman_wizard_step"] = "total_amount_quote" + await _dman_show_amount_step(update, context) + +async def _dman_show_amount_step(update, context) -> None: + """DMan Step: Enter Total Amount""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + leverage = config.get("leverage", 1) + pos_mode = config.get("position_mode", "HEDGE") + + context.user_data["bots_state"] = "dman_wizard_input" + context.user_data["dman_wizard_step"] = "total_amount_quote" + connector = config.get("connector_name", "").lower() + is_perp = "_perpetual" in connector or "_margin" in connector + total_steps = 6 if is_perp else 4 + current_step = 5 if is_perp else 3 + + # Fetch balance + balance_text = "" + try: + client, _ = await get_bots_client(chat_id, context.user_data) + balances = await get_cex_balances( + context.user_data, client, "master_account", ttl=30 + ) + + # Flexible matching come in Grid Strike + 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", "") + if bal_lower == connector_lower or bal_base == connector_base: + connector_balances = bal_list + break + + if connector_balances: + relevant_balances = [] + quote = pair.split("-")[1] if "-" in pair else "USDT" + for bal in connector_balances: + token = bal.get("token", bal.get("asset", "")) + available = bal.get("units", bal.get("available_balance", bal.get("free", 0))) + if token and token.upper() == quote.upper(): + try: + available_float = float(available) + if available_float > 0: + balance_text = f"\n\nπŸ’° Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`" + break + except (ValueError, TypeError): + continue + except Exception as e: + logger.warning(f"Could not fetch balances for D-Man v.3 amount step: {e}") + # Gestione dinamica del tasto Back + back_callback = "bots:dman_back_to_position_mode" if is_perp else "bots:dman_back_to_pair" + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:dman_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:dman_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:dman_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:dman_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:dman_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:dman_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data=back_callback), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + # Testo del messaggio con riepilogo parametri scelti finora + header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + if is_perp: + header_info += f" \\| ⚑ `{leverage}x` \\| 🎯 `{pos_mode}`" + + message_text = ( + rf"*πŸ“‰ DMan V3 \- Step {current_step}/{total_steps}*" + "\n\n" + + header_info + balance_text + "\n\n" + r"πŸ’° *Total Amount \(USDT\)*" + "\n" + r"_Select or type an amount:_" + ) + + # ========== SALVA MESSAGE_ID ========== + target_chat_id = chat_id + if query and query.message: + target_chat_id = query.message.chat_id + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["dman_wizard_message_id"] = query.message.message_id + return + except Exception: + try: + await query.message.delete() + except Exception: + pass + + new_msg = await context.bot.send_message( + chat_id=target_chat_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["dman_wizard_message_id"] = new_msg.message_id + context.user_data["dman_wizard_chat_id"] = target_chat_id + +async def handle_dman_wizard_amount(update, context, amount: float) -> None: + """Handle amount selection""" + query = update.callback_query + config = get_controller_config(context) + config["total_amount_quote"] = amount + set_controller_config(context, config) + + pair = config.get("trading_pair", "") + await query.message.edit_text( + r"*πŸ“‰ DMan V3 \- New Config*" + "\n\n" + f"⏳ *Loading chart for* `{escape_markdown_v2(pair)}`\\.\\.\\. " + "\n\n" + r"_Fetching market data\.\.\._", + parse_mode="MarkdownV2", + ) + + context.user_data["dman_wizard_step"] = "final" + await _dman_show_final_step(update, context) + + +async def _dman_show_final_step(update, context, interval: str = None) -> None: + """DMan Final Step: Chart + Config Summary""" + query = update.callback_query + msg = query.message if query else update.message + chat_id = update.effective_chat.id + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + total_amount = config.get("total_amount_quote", 1000) + leverage = config.get("leverage", 1) + bb_length = config.get("bb_length", 100) + + if interval is None: + interval = context.user_data.get("dman_chart_interval", config.get("interval", "5m")) + context.user_data["dman_chart_interval"] = interval + config["interval"] = interval + set_controller_config(context, config) + + current_price = context.user_data.get("dman_current_price") + candles = context.user_data.get("dman_candles") + + try: + cached_interval = context.user_data.get("dman_candles_interval", interval) + if not current_price or interval != cached_interval: + try: + await msg.edit_text( + r"*πŸ“‰ DMan V3 \- New Config*" + "\n\n" + f"⏳ Fetching market data for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + except Exception: + pass + client, _ = await get_bots_client(chat_id, context.user_data) + + # --- INIZIO MODIFICA FIX GRAFICO --- + # Puliamo il nome del connettore per le candele (es: kucoin_perpetual -> kucoin) + # Questo evita l'errore 500 sui futures + candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "") + # ----------------------------------- + + current_price = await fetch_current_price(client, connector, pair) + + if current_price: + context.user_data["dman_current_price"] = current_price + candles = await fetch_candles( + client, candles_connector, pair, interval=interval, max_records=420 # <--- Usiamo candles_connector + ) + context.user_data["dman_candles"] = candles + context.user_data["dman_candles_interval"] = interval + + if not current_price: + keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]] + await msg.edit_text( + r"*❌ Error*" + "\n\n" + f"Could not fetch price for `{escape_markdown_v2(pair)}`\\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + candles_list = candles.get("data", []) if isinstance(candles, dict) else (candles or []) + + if candles_list: + last_close = candles_list[-1].get("close") or candles_list[-1].get("c") + if last_close: + current_price = float(last_close) + context.user_data["dman_current_price"] = current_price + + if not config.get("id"): + existing_configs = context.user_data.get("controller_configs_list", []) + from .controllers.dman_v3 import generate_id as dman_generate_id + config["id"] = dman_generate_id(config, existing_configs) + + from .controllers.dman_v3.analysis import analyze_candles_for_dman, format_dman_analysis, get_dca_strategy_suggestions + bb_length = config.get("bb_length", 20) + bb_std_val = config.get("bb_std", 2.0) + analysis = analyze_candles_for_dman(candles_list, bb_length=bb_length, bb_std=bb_std_val) + + config["bb_long_threshold"] = analysis["suggested_long_threshold"] + config["bb_short_threshold"] = analysis["suggested_short_threshold"] + if analysis["suggested_dca_spreads"]: + config["dca_spreads"] = ",".join(str(s) for s in analysis["suggested_dca_spreads"]) + if "dca_amounts_pct" not in config or config["dca_amounts_pct"] is None: + config["dca_amounts_pct"] = "" + set_controller_config(context, config) + + config_id = config.get("id", "") + position_mode = config.get("position_mode", "HEDGE") + stop_loss = config.get("stop_loss", 0.05) + take_profit = config.get("take_profit", 0.03) + max_exec = config.get("max_executors_per_side", 1) + cooldown = config.get("cooldown_time", 60) + dca_spreads = config.get("dca_spreads", "0.001,0.018,0.15,0.25") + bb_std = config.get("bb_std", 2.0) + bb_long = config.get("bb_long_threshold", 0.0) + bb_short = config.get("bb_short_threshold", 1.0) + ts = config.get("trailing_stop", {}) or {} + ts_act = ts.get("activation_price", 0.015) if isinstance(ts, dict) else 0.015 + ts_delta = ts.get("trailing_delta", 0.005) if isinstance(ts, dict) else 0.005 + + context.user_data["bots_state"] = "dman_wizard_input" + context.user_data["dman_wizard_step"] = "final" + + interval_options = ["1m", "5m", "15m", "1h", "8h"] + interval_row = [ + InlineKeyboardButton( + f"βœ“ {opt}" if opt == interval else opt, + callback_data=f"bots:dman_interval:{opt}" + ) + for opt in interval_options + ] +# Recuperiamo il NATR dall'analisi fatta precedentemente (punto dove hai chiamato analyze_candles_for_dman) + natr_val = analysis.get("natr", 0.01) + # Salviamo l'analisi in user_data per recuperarla quando l'utente clicca i bottoni + context.user_data["dman_analysis"] = analysis + + # Generiamo i bottoni usando le chiavi che abbiamo definito in analysis.py + strategy_row = [ + InlineKeyboardButton("🎯 Scalp", callback_data="bots:dman_set_strat:scalping"), + InlineKeyboardButton("🎲 Marti", callback_data="bots:dman_set_strat:martingale"), + InlineKeyboardButton("βš–οΈ Def", callback_data="bots:dman_set_strat:standard"), + InlineKeyboardButton("πŸ›‘οΈ Cons", callback_data="bots:dman_set_strat:conservative"), + InlineKeyboardButton("πŸ€– Auto", callback_data="bots:dman_set_strat:auto"), + ] + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + final_step = 6 if is_perp else 4 + + keyboard = [ + interval_row, + strategy_row, # <--- AGGIUNGI QUESTA RIGA QUI + [InlineKeyboardButton("πŸ’Ύ Save Config", callback_data="bots:dman_save")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_amount"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + config_text = ( + rf"*πŸ“‰ DMan V3 \- Step {final_step}/{final_step} \(Final\)*" + "\n\n" + f"*{escape_markdown_v2(pair)}*\n" + f"Price: `{current_price:,.6g}` \\| BB: `{bb_length}` \\| Interval: `{interval}`\n\n" + f"`connector_name={connector}`\n" + f"`trading_pair={pair}`\n" + f"`total_amount_quote={total_amount:.0f}`\n" + f"`leverage={leverage}`\n" + f"`position_mode={position_mode}`\n" + f"`max_executors_per_side={max_exec}`\n" + f"`cooldown_time={cooldown}`\n" + f"`stop_loss={stop_loss}`\n" + f"`take_profit={take_profit}`\n" + f"`trailing_stop_activation={ts_act}`\n" + f"`trailing_stop_delta={ts_delta}`\n" + f"`interval={interval}`\n" + f"`bb_length={bb_length}`\n" + f"`bb_std={bb_std}`\n" + f"`bb_long_threshold={bb_long}`\n" + f"`bb_short_threshold={bb_short}`\n" + f"`dca_spreads={escape_markdown_v2(str(dca_spreads))}`\n" + f"`dca_amounts_pct={config.get('dca_amounts_pct', '')}`\n" + r"_Edit: `field=value`_" + ) + + analysis_text = format_dman_analysis(analysis) + config_text += "\n\n```\n" + analysis_text + "\n```" + + if candles_list: + from .controllers.dman_v3.chart import generate_chart as dman_chart + chart_bytes = dman_chart(config, candles_list, current_price) + + try: + await msg.delete() + except Exception: + pass + + new_msg = await context.bot.send_photo( + chat_id=chat_id, + photo=chart_bytes, + caption=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["dman_wizard_message_id"] = new_msg.message_id + context.user_data["dman_wizard_chat_id"] = chat_id + else: + try: + await msg.edit_text( + text=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["dman_wizard_message_id"] = new_msg.message_id + + except Exception as e: + logger.error(f"DMan final step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + try: + await msg.edit_text( + format_error_message(f"Error: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + pass + +async def handle_dman_interval_change(update, context, interval: str) -> None: + """Change chart interval""" + context.user_data["dman_candles"] = None + context.user_data["dman_candles_interval"] = None + await _dman_show_final_step(update, context, interval=interval) + +async def handle_dman_set_strategy(update, context, strat_key: str) -> None: + """Handle DCA strategy selection from the final step buttons""" + query = update.callback_query + config = get_controller_config(context) + + # Recuperiamo l'analisi NATR salvata in precedenza in _dman_show_final_step + analysis = context.user_data.get("dman_analysis", {}) + natr = analysis.get("natr", 0.01) + + from .controllers.dman_v3.analysis import get_dca_strategy_suggestions + strats = get_dca_strategy_suggestions(natr) + + if strat_key in strats: + selected = strats[strat_key] + # Aggiorniamo la configurazione con i valori della strategia scelta + config["dca_spreads"] = ",".join(str(s) for s in selected["dca_spreads"]) + config["dca_amounts_pct"] = ",".join(str(a) for a in selected["dca_amounts_pct"]) + + # Applichiamo anche i threshold suggeriti dall'analisi BB + config["bb_long_threshold"] = analysis.get("suggested_long_threshold", 0.0) + config["bb_short_threshold"] = analysis.get("suggested_short_threshold", 1.0) + + set_controller_config(context, config) + + # Feedback visivo all'utente + await query.answer(f"βœ… Strategia {selected['label']} applicata") + + # Ricarichiamo la schermata finale per mostrare i nuovi valori nel testo e nel grafico + return await _dman_show_final_step(update, context) + +async def handle_dman_save(update, context) -> None: + """Save DMan V3 configuration""" + query = update.callback_query + config = get_controller_config(context) + + # 1. Riconoscimento del tipo di connettore + connector = config.get("connector_name", "").lower() + is_perp = "_perpetual" in connector or "_margin" in connector + + # 2. ========== LOGICA DI PULIZIA E VALIDAZIONE ========== + # Se Γ¨ SPOT, forziamo i parametri corretti a prescindere dall'input utente + if not is_perp: + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + else: + # Se Γ¨ Perpetual/Margin e non Γ¨ stato impostato un position_mode, mettiamo HEDGE + if not config.get("position_mode"): + config["position_mode"] = "HEDGE" + + # ========== PULISCI I CAMPI NON NECESSARI ========== + config.pop("candles_config", None) + config.pop("manual_kill_switch", None) + # dca_amounts_pct vuoto β†’ rimuovilo, hummingbot usa distribuzione uguale di default + if not config.get("dca_amounts_pct"): + config.pop("dca_amounts_pct", None) + # =================================================== + + config_id = config.get("id", "") + chat_id = query.message.chat_id + + try: + await query.message.delete() + except Exception: + pass + + status_msg = await context.bot.send_message( + chat_id=chat_id, + text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + await client.controllers.create_or_update_controller_config(config_id, config) + + # Cleanup dati temporanei del wizard + for key in ["dman_wizard_step", "dman_wizard_message_id", "dman_wizard_chat_id", + "dman_current_price", "dman_candles", "dman_candles_interval", + "dman_chart_interval", "controller_config"]: # Aggiunto controller_config per sicurezza + context.user_data.pop(key, None) + context.user_data.pop("bots_state", None) + + keyboard = [ + [InlineKeyboardButton("Create Another", callback_data="bots:new_dman_v3")], + [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")], + ] + saved_msg = ( + "*Config Saved\\!*\n\n" + "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\." + ) + await status_msg.edit_text( + saved_msg, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"DMan save error: {e}", exc_info=True) + keyboard = [ + [InlineKeyboardButton("Try Again", callback_data="bots:dman_save")], + [InlineKeyboardButton("Back", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + format_error_message(f"Failed to save: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) +async def handle_dman_back_to_connector(update, context) -> None: + context.user_data["dman_wizard_step"] = "connector_name" + await _dman_show_connector_step(update, context) + + +async def handle_dman_back_to_pair(update, context) -> None: + context.user_data["dman_wizard_step"] = "trading_pair" + await _dman_show_pair_step(update, context) + +async def handle_dman_back_to_leverage(update, context) -> None: + config = get_controller_config(context) + connector = config.get("connector_name", "") + # Usa la stessa logica degli altri wizard + is_perp = "_perpetual" in connector or "_margin" in connector + if is_perp: + context.user_data["dman_wizard_step"] = "leverage" + await _dman_show_leverage_step(update, context) + else: + await handle_dman_back_to_pair(update, context) + + +async def handle_dman_back_to_amount(update, context) -> None: + context.user_data["dman_wizard_step"] = "total_amount_quote" + context.user_data.pop("dman_current_price", None) + context.user_data.pop("dman_candles", None) + context.user_data.pop("dman_candles_interval", None) + await _dman_show_amount_step(update, context) + +async def handle_dman_back_to_position_mode(update, context) -> None: + context.user_data["dman_wizard_step"] = "position_mode" + await _dman_show_position_mode_step(update, context) + +async def handle_dman_pair_select( + update: Update, context: ContextTypes.DEFAULT_TYPE, trading_pair: str +) -> None: + """Handle selection of a suggested trading pair in DMan wizard""" + config = get_controller_config(context) + chat_id = update.effective_chat.id + + # Clear old market data + for key in ["dman_current_price", "dman_candles", "dman_candles_interval", "dman_chart_interval"]: + context.user_data.pop(key, None) + + config["trading_pair"] = trading_pair + config["candles_trading_pair"] = trading_pair + set_controller_config(context, config) + + # Move to next step based on connector type + connector = config.get("connector_name", "") + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + if is_perp: + context.user_data["dman_wizard_step"] = "leverage" + await _dman_show_leverage_step(update, context) + else: + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + set_controller_config(context, config) + context.user_data["dman_wizard_step"] = "total_amount_quote" + await _dman_show_amount_step(update, context) + +async def process_dman_wizard_input(update, context, user_input: str) -> None: + """Process text input during DMan V3 wizard""" + step = context.user_data.get("dman_wizard_step") + chat_id = update.effective_chat.id + config = get_controller_config(context) + message_id = context.user_data.get("dman_wizard_message_id") + wizard_chat_id = context.user_data.get("dman_wizard_chat_id", chat_id) + try: + try: + await update.message.delete() + except Exception: + pass + + if step == "trading_pair": + pair = user_input.upper().strip().replace("/", "-").replace("_", "-") + connector = config.get("connector_name", "") + + # ========== VALIDAZIONE BASE: DEVE CONTENERE IL TRATTINO ========== + if "-" not in pair: + message_id = context.user_data.get("dman_wizard_message_id") + wizard_chat_id = context.user_data.get("dman_wizard_chat_id", chat_id) + + # CORRETTO: keyboard come lista di liste + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_connector")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + ] + + # Mostra il contesto con exchange selezionato + context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n" + + err_text = ( + r"*πŸ“‰ DMan V3 \- Step 2*" + "\n\n" + + context_text + + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n" + r"Type the pair again:" + ) + + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + else: + msg = await context.bot.send_message( + chat_id=chat_id, text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["dman_wizard_message_id"] = msg.message_id + return + + # ========== 2. VALIDAZIONE SULL'EXCHANGE (CON SUGGERIMENTI) ========== + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = ( + await validate_trading_pair(context.user_data, client, connector, pair) + ) + + if not is_valid: + message_id = context.user_data.get("dman_wizard_message_id") + wizard_chat_id = context.user_data.get("dman_wizard_chat_id", chat_id) + + # CORRETTO: costruisci la keyboard come lista di liste + keyboard = [] + for sugg in suggestions[:4]: + keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:dman_pair_select:{sugg}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_connector")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + # Mostra il contesto con exchange selezionato + context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n" + + err_text = ( + r"*πŸ“‰ DMan V3 \- Step 2*" + "\n\n" + + context_text + + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n" + r"*Did you mean?*" + ) + + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + else: + msg = await context.bot.send_message( + chat_id=chat_id, text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["dman_wizard_message_id"] = msg.message_id + return + + if correct_pair: + pair = correct_pair + + config["trading_pair"] = pair + config["candles_trading_pair"] = pair + for key in ["dman_current_price", "dman_candles", "dman_candles_interval"]: + context.user_data.pop(key, None) + set_controller_config(context, config) # Advance to next step + connector = config.get("connector_name", "") + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + message_id = context.user_data.get("dman_wizard_message_id") + wizard_chat_id = context.user_data.get("dman_wizard_chat_id", chat_id) + + if is_perp: + context.user_data["dman_wizard_step"] = "leverage" + leverage = config.get("leverage", 1) + keyboard = [ + [ + InlineKeyboardButton("1x", callback_data="bots:dman_leverage:1"), + InlineKeyboardButton("5x", callback_data="bots:dman_leverage:5"), + InlineKeyboardButton("10x", callback_data="bots:dman_leverage:10"), + ], + [ + InlineKeyboardButton("20x", callback_data="bots:dman_leverage:20"), + InlineKeyboardButton("50x", callback_data="bots:dman_leverage:50"), + InlineKeyboardButton("75x", callback_data="bots:dman_leverage:75"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + text = ( + r"*πŸ“‰ DMan V3 \- Step 3/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n\n" + r"⚑ *Select Leverage*" + "\n" + r"_Or type a value \(e\.g\. 20\)_" + ) + else: + config["leverage"] = 1 + set_controller_config(context, config) + context.user_data["dman_wizard_step"] = "total_amount_quote" + context.user_data["bots_state"] = "dman_wizard_input" + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:dman_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:dman_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:dman_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:dman_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:dman_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:dman_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + text = ( + r"*πŸ“‰ DMan V3 \- Step 3/4*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n\n" + r"πŸ’° *Total Amount \(USDT\)*" + "\n" + r"_Select or type an amount:_" + ) + + try: + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["dman_wizard_message_id"] = message_id + except Exception: + pass + return + elif step == "leverage": + val = int(float(user_input.strip().lower().replace("x", ""))) + config["leverage"] = val + set_controller_config(context, config) + context.user_data["dman_wizard_step"] = "total_amount_quote" + message_id = context.user_data.get("dman_wizard_message_id") + wizard_chat_id = context.user_data.get("dman_wizard_chat_id", chat_id) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:dman_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:dman_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:dman_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:dman_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:dman_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:dman_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_leverage"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + text = ( + r"*πŸ“‰ DMan V3 \- Step 4/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}` \\| ⚑ `{val}x`" + "\n\n" + r"πŸ’° *Total Amount \(USDT\)*" + "\n" + r"_Select or type an amount:_" + ) + context.user_data["bots_state"] = "dman_wizard_input" + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + elif step == "total_amount_quote": + amount = float(user_input.strip().replace("$", "").replace(",", "")) + config["total_amount_quote"] = amount + set_controller_config(context, config) + context.user_data["dman_wizard_step"] = "final" + wizard_chat_id = context.user_data.get("dman_wizard_chat_id", chat_id) + pair = config.get("trading_pair", "") + tmp = await context.bot.send_message( + chat_id=wizard_chat_id, + text=r"*πŸ“‰ DMan V3*" + "\n\n" + f"⏳ Loading chart for `{escape_markdown_v2(pair)}`\\.\\.\\. ", + parse_mode="MarkdownV2", + ) + context.user_data["dman_wizard_message_id"] = tmp.message_id + await _dman_show_final_step(update, context) + + elif step == "final": + # Handle field=value edits + if "=" in user_input: + for line in user_input.strip().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() + try: + if field in ("total_amount_quote", "stop_loss", "take_profit", + "bb_std", "bb_long_threshold", "bb_short_threshold", + "trailing_stop_activation", "trailing_stop_delta"): + val = float(value) + if field == "trailing_stop_activation": + if not isinstance(config.get("trailing_stop"), dict): + config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005} + config["trailing_stop"]["activation_price"] = val + elif field == "trailing_stop_delta": + if not isinstance(config.get("trailing_stop"), dict): + config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005} + config["trailing_stop"]["trailing_delta"] = val + else: + config[field] = val + elif field in ("leverage", "bb_length", "max_executors_per_side", + "cooldown_time", "take_profit_order_type"): + config[field] = int(float(value)) + elif field in ("dynamic_order_spread", "dynamic_target"): + config[field] = value.lower() in ("true", "yes", "1") + elif field == "interval": + config["interval"] = value + # Clear candles to refresh chart + context.user_data.pop("dman_candles", None) + context.user_data["dman_chart_interval"] = value + else: + config[field] = value + except Exception: + pass + set_controller_config(context, config) + + except Exception as e: + logger.error(f"DMan wizard input error: {e}", exc_info=True) + + +# ============================================ +# ARBITRAGE CONTROLLER WIZARD +# ============================================ +# Steps: connector1 β†’ pair1 β†’ connector2 β†’ pair2 β†’ amount β†’ final+save +# Prefisso handler: arb_ + +async def show_new_arbitrage_controller_form(update, context) -> None: + """Start the Arbitrage Controller wizard - Step 1: Exchange 1""" + query = update.callback_query + chat_id = update.effective_chat.id + + for key in ["arb_price_1", "arb_price_2"]: + context.user_data.pop(key, None) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + 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}") + + init_new_controller_config(context, "arbitrage_controller") + context.user_data["bots_state"] = "arb_wizard" + context.user_data["arb_wizard_step"] = "connector_1" # Step 1: primo exchange + context.user_data["arb_wizard_message_id"] = query.message.message_id + context.user_data["arb_wizard_chat_id"] = query.message.chat_id + await _arb_show_connector_step(update, context, exchange_num=1) + +async def _arb_show_connector_step(update, context, exchange_num: int, target_message_id: int = None) -> None: + """Show connector selection for exchange 1 or 2""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + + # Usa target_message_id se fornito + use_message_id = target_message_id + if not query and not use_message_id: + logger.error("_arb_show_connector_step called without callback_query and without target_message_id") + return + + if not query and use_message_id: + wizard_chat_id = context.user_data.get("arb_wizard_chat_id", chat_id) + + try: + client, server_name = await get_bots_client(chat_id, context.user_data) + cex_connectors = await get_available_cex_connectors( + context.user_data, client, server_name=server_name + ) + + # DEX connectors via Gateway + dex_connectors = ["jupiter/router", "uniswap/ethereum", "uniswap/base", + "uniswap/arbitrum", "uniswap/bsc", "pancakeswap/bsc", + "raydium/solana"] + + # ========== CORREZIONE STEP NUMBER ========== + # exchange_num=1 -> step 1, exchange_num=2 -> step 3 + if exchange_num == 1: + step = 1 + else: + step = 3 + # =========================================== + + total_steps = 6 + emoji = "1️⃣" if exchange_num == 1 else "2️⃣" + role = "Buy" if exchange_num == 1 else "Sell" + + # Show context for step 3 (second exchange) + header = "" + if exchange_num == 2: + ep1 = config.get("exchange_pair_1", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + header = f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n\n" + + keyboard = [] + if cex_connectors: + keyboard.append([InlineKeyboardButton("β€” CEX β€”", callback_data="bots:noop")]) + row = [] + for c in cex_connectors: + row.append(InlineKeyboardButton(c, callback_data=f"bots:arb_connector_{exchange_num}:{c}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + if dex_connectors: + keyboard.append([InlineKeyboardButton("β€” DEX (Gateway) β€”", callback_data="bots:noop")]) + row = [] + for d in dex_connectors: + row.append(InlineKeyboardButton(d, callback_data=f"bots:arb_connector_{exchange_num}:{d}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + back_cb = "bots:main_menu" if exchange_num == 1 else "bots:arb_back_to_pair_1" + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data=back_cb), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + message_text = ( + rf"*⚑ Arbitrage \- Step {step}/{total_steps}*" + "\n\n" + r"Buy on one exchange, sell on another when spread exceeds min profitability\." + "\n\n" + r"─────────────────────────" + "\n\n" + + header + + rf"*{emoji} Select Exchange {exchange_num} \({role}\):*" + ) + + # Invia/edita il messaggio + if query and query.message: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + elif use_message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"Error editing message: {e}") + new_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["arb_wizard_message_id"] = new_msg.message_id + + except Exception as e: + logger.error(f"Arb connector step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + error_text = format_error_message(f"Error: {str(e)}") + + if query and query.message: + await query.message.edit_text( + error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + elif use_message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + pass + + + +async def handle_arb_wizard_connector_1(update, context, connector: str) -> None: + """Handle Exchange 1 connector selection""" + config = get_controller_config(context) + if "exchange_pair_1" not in config: + config["exchange_pair_1"] = {} + config["exchange_pair_1"]["connector_name"] = connector + # Auto-set rate_connector to first CEX (not DEX) + if "/" not in connector: + config["rate_connector"] = connector.replace("_perpetual", "").replace("_spot", "") + set_controller_config(context, config) + + # VAI AL PAIR 1 (step 2) + context.user_data["arb_wizard_step"] = "pair_1" + await _arb_show_pair_step(update, context, exchange_num=1) + +async def handle_arb_wizard_connector_2(update, context, connector: str) -> None: + """Handle Exchange 2 connector selection""" + config = get_controller_config(context) + if "exchange_pair_2" not in config: + config["exchange_pair_2"] = {} + config["exchange_pair_2"]["connector_name"] = connector + set_controller_config(context, config) + + # VAI AL PAIR 2 (step 4) + context.user_data["arb_wizard_step"] = "pair_2" + await _arb_show_pair_step(update, context, exchange_num=2) + +async def _arb_show_pair_step(update, context, exchange_num: int) -> None: + """Show trading pair input for exchange 1 or 2""" + query = update.callback_query + config = get_controller_config(context) + + ep_key = f"exchange_pair_{exchange_num}" + connector = config.get(ep_key, {}).get("connector_name", "") + + context.user_data["bots_state"] = "arb_wizard_input" + context.user_data["arb_wizard_step"] = f"pair_{exchange_num}" + + # Calcola lo step corretto + if exchange_num == 1: + step = 2 + emoji = "1️⃣" + else: + step = 4 + emoji = "2️⃣" + + total_steps = 6 + + # Show context per exchange 2 + header = "" + if exchange_num == 2: + ep1 = config.get("exchange_pair_1", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + c2 = config.get("exchange_pair_2", {}).get("connector_name", "") + header = ( + f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"2️⃣ `{escape_markdown_v2(c2)}`\n\n" + ) + else: + c1 = config.get("exchange_pair_1", {}).get("connector_name", "") + header = f"1️⃣ `{escape_markdown_v2(c1)}`\n\n" + + # ... resto del codice invariato ... + + # Recent pairs from existing configs + existing_configs = context.user_data.get("controller_configs_list", []) + recent_pairs = [] + seen = set() + for cfg in reversed(existing_configs): + ep = cfg.get(ep_key, {}) + pair = ep.get("trading_pair", "") if isinstance(ep, dict) else "" + if pair and pair not in seen: + seen.add(pair) + recent_pairs.append(pair) + if len(recent_pairs) >= 6: + break + + # Suggest same pair as exchange 1 for exchange 2 + if exchange_num == 2: + p1 = config.get("exchange_pair_1", {}).get("trading_pair", "") + if p1 and p1 not in seen: + recent_pairs.insert(0, p1) + + keyboard = [] + if recent_pairs: + row = [] + for pair in recent_pairs[:6]: + row.append(InlineKeyboardButton(pair, callback_data=f"bots:arb_pair_{exchange_num}:{pair}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + back_cb = f"bots:arb_back_to_connector_{exchange_num}" + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data=back_cb), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + is_dex = "/" in connector + hint = r"_e\.g\. SOL\-USDC_" if is_dex else r"_e\.g\. SOL\-USDT_" + + await query.message.edit_text( + rf"*⚑ Arbitrage \- Step {step}/6*" + "\n\n" + + header + + rf"*{emoji} Trading Pair on* `{escape_markdown_v2(connector)}`:" + "\n\n" + + hint + "\n\n" + r"Select or type a pair:", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_arb_wizard_pair_1(update: Update, context: ContextTypes.DEFAULT_TYPE, pair: str) -> None: + """Handle pair selection for Exchange 1""" + config = get_controller_config(context) + + if "exchange_pair_1" not in config: + config["exchange_pair_1"] = {} + config["exchange_pair_1"]["trading_pair"] = pair.upper() + set_controller_config(context, config) + + # VAI AL CONNECTOR 2 (step 3) - NON al pair 2 + context.user_data["arb_wizard_step"] = "connector_2" + + # Mostra la selezione del secondo exchange + await _arb_show_connector_step(update, context, exchange_num=2) + +async def handle_arb_wizard_pair_2(update, context, pair: str) -> None: + """Handle pair selection for Exchange 2""" + config = get_controller_config(context) + if "exchange_pair_2" not in config: + config["exchange_pair_2"] = {} + config["exchange_pair_2"]["trading_pair"] = pair.upper() + + # Auto-set quote_conversion_asset from first pair (or second) + ep1 = config.get("exchange_pair_1", {}) + p1 = ep1.get("trading_pair", "") + if p1: + quote = p1.split("-")[1] if "-" in p1 else "USDT" + else: + quote = pair.split("-")[1] if "-" in pair else "USDT" + config["quote_conversion_asset"] = quote + + set_controller_config(context, config) + + # VAI ALL'AMOUNT (step 5) + context.user_data["arb_wizard_step"] = "total_amount_quote" + await _arb_show_amount_step(update, context) + + +async def _arb_show_amount_step(update, context) -> None: + """Arb Step 5: Enter Total Amount""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + + ep1 = config.get("exchange_pair_1", {}) + ep2 = config.get("exchange_pair_2", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + c2 = ep2.get("connector_name", "") + p2 = ep2.get("trading_pair", "") + + context.user_data["bots_state"] = "arb_wizard_input" + context.user_data["arb_wizard_step"] = "total_amount_quote" + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:arb_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:arb_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:arb_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:arb_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:arb_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:arb_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_pair_2"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + message_text = ( + r"*⚑ Arbitrage \- Step 5/6*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"2️⃣ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n" + r"πŸ’° *Total Amount per side \(Quote\)*" + "\n" + r"_Select or type an amount:_" + ) + + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + 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_arb_wizard_amount(update, context, amount: float) -> None: + """Handle amount selection from button click""" + query = update.callback_query + config = get_controller_config(context) + config["total_amount_quote"] = amount + set_controller_config(context, config) + + ep1 = config.get("exchange_pair_1", {}) + pair = ep1.get("trading_pair", "") + await query.message.edit_text( + r"*⚑ Arbitrage \- New Config*" + "\n\n" + f"⏳ Fetching prices for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + context.user_data["arb_wizard_step"] = "final" + await _arb_show_final_step(update, context) + + +async def _arb_show_final_step(update, context) -> None: + """Arb Final Step: Show chart + config summary with supported fields""" + from .controllers.arbitrage_controller import ArbitrageControllerController + from .controllers.arbitrage_controller.chart import generate_chart + + query = update.callback_query + msg = query.message if query else update.message + chat_id = update.effective_chat.id + config = get_controller_config(context) + + ep1 = config.get("exchange_pair_1", {}) + ep2 = config.get("exchange_pair_2", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + c2 = ep2.get("connector_name", "") + p2 = ep2.get("trading_pair", "") + total_amount = config.get("total_amount_quote", 1000) + min_prof = config.get("min_profitability", 0.01) + delay = config.get("delay_between_executors", 10) + max_imbalance = config.get("max_executors_imbalance", 1) + rate_connector = config.get("rate_connector", "binance") + quote_asset = config.get("quote_conversion_asset", "USDT") + + if "controller_type" not in config: + config["controller_type"] = "generic" + set_controller_config(context, config) + + if not config.get("id"): + existing_configs = context.user_data.get("controller_configs_list", []) + from .controllers.arbitrage_controller.config import generate_id as arb_generate_id + config["id"] = arb_generate_id(config, existing_configs) + set_controller_config(context, config) + + # ========== FETCH CANDLES ========== + candles1 = [] + candles2 = [] + + # Prendi i parametri dalla config (con default) + interval = config.get("backtest_interval", "5m") + max_candles = config.get("backtest_candles", 500) + + # Limita a un massimo di 1000 per evitare problemi + max_candles = min(max_candles, 1000) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + + if "/" not in c1: + candles_data = await fetch_candles(client, c1, p1, interval=interval, max_records=max_candles) + candles1 = candles_data.get("data", []) if isinstance(candles_data, dict) else (candles_data or []) + logger.info(f"Fetched {len(candles1)} candles for {p1} ({interval})") + + if "/" not in c2: + candles_data2 = await fetch_candles(client, c2, p2, interval=interval, max_records=max_candles) + candles2 = candles_data2.get("data", []) if isinstance(candles_data2, dict) else (candles_data2 or []) + logger.info(f"Fetched {len(candles2)} candles for {p2} ({interval})") + except Exception as e: + logger.warning(f"Could not fetch candles for arb chart: {e}") + + # ========== RECUPERA FEES E ANALISI STORICA ========== + fee_1 = 0.001 # default 0.1% + fee_2 = 0.001 # default 0.1% + analysis_text = "" + + if candles1 and candles2: + try: + from .controllers.arbitrage_controller.analysis import analyze_historical_spread + + # Recupera trading rules per ottenere le fee reali + trading_rules_1 = await get_trading_rules(context.user_data, client, c1) + fee_1 = trading_rules_1.get(p1, {}).get('taker_fee', 0.001) + + trading_rules_2 = await get_trading_rules(context.user_data, client, c2) + fee_2 = trading_rules_2.get(p2, {}).get('taker_fee', 0.001) + + # Salva le fees nel config per il chart + config["fee_rate_exchange_1"] = fee_1 + config["fee_rate_exchange_2"] = fee_2 + set_controller_config(context, config) + + logger.info(f"Fees: {c1}={fee_1*100:.2f}%, {c2}={fee_2*100:.2f}%") + + # Esegui analisi storica dello spread + analysis_result = await analyze_historical_spread( + candles1, candles2, config, fee_1, fee_2 + ) + + if "error" not in analysis_result: + stats = analysis_result["statistics"] + suggested = analysis_result["suggested_min_profitability"] + is_arb = analysis_result["is_arbitrageable"] + + # Aggiorna min_profitability con valore suggerito + if suggested > 0: + config["min_profitability"] = suggested + min_prof = suggested + set_controller_config(context, config) + + # USA escape_markdown_v2 per tutti i valori numerici e testi dinamici + samples = analysis_result['total_samples'] + mean_val = f"{stats.get('mean', 0):.3f}" + median_val = f"{stats.get('median', 0):.3f}" + p75_val = f"{stats.get('p75', 0):.3f}" + p90_val = f"{stats.get('p90', 0):.3f}" + max_val = f"{stats.get('max', 0):.3f}" + fees_val = f"{analysis_result['total_fees_percent']:.2f}" + raw_text = ( + f"\n\nπŸ“Š Spread Statistics - last {analysis_result['total_samples']} candles, {interval}:\n" + f" Mean: {stats.get('mean', 0):.3f}% | Median: {stats.get('median', 0):.3f}%\n" + f" P75: {stats.get('p75', 0):.3f}% | P90: {stats.get('p90', 0):.3f}% | Max: {stats.get('max', 0):.3f}%\n" + f" Fees total: {analysis_result['total_fees_percent']:.2f}%\n" + ) + # Escapa tutto il testo + analysis_text = escape_markdown_v2(raw_text) + if is_arb: + analysis_text += f" βœ… *Arbitrageable!* πŸ’‘ Suggested min_profitability: {suggested*100:.3f}%" + else: + analysis_text += " ⚠️ *Not profitable after fees* \\- spread P75 below fees" + except Exception as e: + logger.warning(f"Could not run historical analysis: {e}") + analysis_text = f"\n\n⚠️ *Could not analyze spread data* (historical analysis failed)" + + # ========== FETCH LIVE PRICES FOR SPREAD (optional) ========== + spread_text = "" + price_1 = None + price_2 = None + try: + client, _ = await get_bots_client(chat_id, context.user_data) + + if "/" not in c1: + price_1 = await fetch_current_price(client, c1, p1) + context.user_data["arb_price_1"] = price_1 + + if "/" not in c2: + price_2 = await fetch_current_price(client, c2, p2) + context.user_data["arb_price_2"] = price_2 + + if price_1 and price_2: + spread_pct = abs(price_1 - price_2) / min(price_1, price_2) * 100 + spread_text = ( + f"\n\nπŸ“Š *Live Spread:*\n" + f" `{escape_markdown_v2(c1)}`: `{price_1:,.6g}`\n" + f" `{escape_markdown_v2(c2)}`: `{price_2:,.6g}`\n" + f" Spread: `{spread_pct:.3f}%`\n" + ) + if spread_pct > min_prof * 100: + spread_text += r" βœ… _Current spread \\> min profitability_" + else: + spread_text += r" ⚠️ _Current spread \\< min profitability_" + elif price_1: + spread_text = ( + f"\n\nπŸ“Š *Live Price:*\n" + f" `{escape_markdown_v2(c1)}`: `{price_1:,.6g}`\n" + r" _DEX price not available via API_" + ) + except Exception as e: + logger.warning(f"Could not fetch arb prices: {e}") + + context.user_data["bots_state"] = "arb_wizard_input" + context.user_data["arb_wizard_step"] = "final" + + # ========== PREPARE DATA FOR CHART ========== + # Passa le candele del secondo exchange come parte della configurazione + config["candles_exchange_2"] = candles2 + + # Genera il grafico se ci sono candele per il primo exchange + chart_bytes = None + if candles1: + try: + chart_bytes = ArbitrageControllerController.generate_chart( + config=config, + candles_data=candles1, + current_price=price_1 or (price_2 if price_2 else None), + grid_analysis=None + ) + except Exception as e: + logger.error(f"Chart generation failed: {e}", exc_info=True) + + # ========== BUILD CONFIG TEXT ========== + config_id = config.get("id", "") + config_text = ( + r"*⚑ Arbitrage \- Final Review*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"2️⃣ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`" + + spread_text + + analysis_text + + "\n\n" + f"`id={escape_markdown_v2(config_id)}`\n" + f"`total_amount_quote={total_amount:.0f}`\n" + f"`min_profitability={min_prof:.6f}`\n" + f"`delay_between_executors={delay}`\n" + f"`max_executors_imbalance={max_imbalance}`\n" + f"`rate_connector={escape_markdown_v2(rate_connector)}`\n" + f"`quote_conversion_asset={escape_markdown_v2(quote_asset)}`\n\n" + r"_Edit: `field=value`_" + ) + + keyboard = [ + [InlineKeyboardButton("πŸ’Ύ Save Config", callback_data="bots:arb_save")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_amount"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + # ========== SEND CHART OR PLAIN TEXT ========== + if chart_bytes and candles1: + try: + await msg.delete() + except Exception: + pass + await context.bot.send_photo( + chat_id=chat_id, + photo=chart_bytes, + caption=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + else: + try: + await msg.edit_text( + config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + try: + await msg.delete() + except Exception: + pass + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["arb_wizard_message_id"] = new_msg.message_id + context.user_data["arb_wizard_chat_id"] = chat_id + +async def handle_arb_save(update, context) -> None: + """Save Arbitrage config - cleans unsupported fields before saving""" + query = update.callback_query + config = get_controller_config(context) + + # ========== PULISCI I CAMPI NON SUPPORTATI ========== + # Campi supportati dal controller originale + supported_fields = [ + "id", + "controller_name", + "controller_type", + "exchange_pair_1", + "exchange_pair_2", + "total_amount_quote", + "min_profitability", + "delay_between_executors", + "max_executors_imbalance", + "rate_connector", + "quote_conversion_asset", + "fee_rate_exchange_1", "fee_rate_exchange_2", "slippage" + ] + if "controller_type" not in config: + config["controller_type"] = "generic" + # Rimuovi tutti i campi non supportati + keys_to_remove = [k for k in config.keys() if k not in supported_fields] + for key in keys_to_remove: + config.pop(key, None) + + # Assicurati che exchange_pair_1 e exchange_pair_2 abbiano solo i campi necessari + for ep_key in ["exchange_pair_1", "exchange_pair_2"]: + if ep_key in config: + # ConnectorPair supporta solo connector_name e trading_pair + ep = config[ep_key] + config[ep_key] = { + "connector_name": ep.get("connector_name", ""), + "trading_pair": ep.get("trading_pair", "") + } + + # =================================================== + + config_id = config.get("id", "") + chat_id = query.message.chat_id + + try: + await query.message.delete() + except Exception: + pass + + status_msg = await context.bot.send_message( + chat_id=chat_id, + text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + await client.controllers.create_or_update_controller_config(config_id, config) + + for key in ["arb_wizard_step", "arb_wizard_message_id", "arb_wizard_chat_id", + "arb_price_1", "arb_price_2"]: + context.user_data.pop(key, None) + context.user_data.pop("bots_state", None) + + keyboard = [ + [InlineKeyboardButton("Create Another", callback_data="bots:new_arbitrage_controller")], + [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + "*Config Saved\\!*\n\n" + "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"Arb save error: {e}", exc_info=True) + keyboard = [ + [InlineKeyboardButton("Try Again", callback_data="bots:arb_save")], + [InlineKeyboardButton("Back", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + format_error_message(f"Failed to save: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +# Back handlers +async def handle_arb_back_to_connector_1(update, context) -> None: + context.user_data["arb_wizard_step"] = "connector_1" + await _arb_show_connector_step(update, context, exchange_num=1) + +async def handle_arb_back_to_connector_2(update, context) -> None: + context.user_data["arb_wizard_step"] = "connector_2" + await _arb_show_connector_step(update, context, exchange_num=2) + +async def handle_arb_back_to_pair_1(update, context) -> None: + context.user_data["arb_wizard_step"] = "pair_1" + await _arb_show_pair_step(update, context, exchange_num=1) + +async def handle_arb_back_to_pair_2(update, context) -> None: + context.user_data["arb_wizard_step"] = "pair_2" + await _arb_show_pair_step(update, context, exchange_num=2) + +async def handle_arb_back_to_amount(update, context) -> None: + context.user_data["arb_wizard_step"] = "total_amount_quote" + context.user_data.pop("arb_price_1", None) + context.user_data.pop("arb_price_2", None) + await _arb_show_amount_step(update, context) + +async def _arb_show_amount_step(update, context, target_message_id: int = None) -> None: + """Arb Step 5: Enter Total Amount""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + + # Usa target_message_id se fornito + use_message_id = target_message_id + if not query and not use_message_id: + logger.error("_arb_show_amount_step called without callback_query and without target_message_id") + return + + if not query and use_message_id: + wizard_chat_id = context.user_data.get("arb_wizard_chat_id", chat_id) + + ep1 = config.get("exchange_pair_1", {}) + ep2 = config.get("exchange_pair_2", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + c2 = ep2.get("connector_name", "") + p2 = ep2.get("trading_pair", "") + + context.user_data["bots_state"] = "arb_wizard_input" + context.user_data["arb_wizard_step"] = "total_amount_quote" + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:arb_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:arb_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:arb_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:arb_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:arb_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:arb_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_pair_2"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + message_text = ( + r"*⚑ Arbitrage \- Step 5/6*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"2️⃣ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n" + r"πŸ’° *Total Amount per side \(Quote\)*" + "\n" + r"_Select or type an amount:_" + ) + + # Invia/edita il messaggio + if query and query.message: + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + 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), + ) + elif use_message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"Error editing message in _arb_show_amount_step: {e}") + # Fallback: invia un nuovo messaggio + new_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["arb_wizard_message_id"] = new_msg.message_id + # ========== RIMUOVI IL BLOCCO elif step == "total_amount_quote" DA QUI ========== + + +async def process_arb_wizard_input(update, context, user_input: str) -> None: + """Process text input during Arbitrage wizard""" + step = context.user_data.get("arb_wizard_step") + chat_id = update.effective_chat.id + config = get_controller_config(context) + message_id = context.user_data.get("arb_wizard_message_id") + wizard_chat_id = context.user_data.get("arb_wizard_chat_id", chat_id) + + try: + try: + await update.message.delete() + except Exception: + pass + + # ========== GESTISCI INPUT MANUALE PER PAIR 1 ========== + if step == "pair_1": + pair = user_input.upper().strip().replace("/", "-").replace("_", "-") + connector = config.get("exchange_pair_1", {}).get("connector_name", "") + + # Validazione base + if "-" not in pair: + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_connector_1")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + ] + error_text = ( + r"*⚑ Arbitrage \- Step 2/6*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(connector)}`\n\n" + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. SOL\-USDT\)*" + "\n\n" + r"Type the pair again:" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + # Validazione sul primo exchange + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = ( + await validate_trading_pair(context.user_data, client, connector, pair) + ) + + if not is_valid: + keyboard = [] + for sugg in suggestions[:4]: + keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:arb_pair_select:{sugg}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_connector_1")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + error_text = ( + r"*⚑ Arbitrage \- Step 2/6*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(connector)}`\n\n" + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n" + r"*Did you mean?*" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + if correct_pair: + pair = correct_pair + + # Imposta la pair per il primo exchange + if "exchange_pair_1" not in config: + config["exchange_pair_1"] = {} + config["exchange_pair_1"]["trading_pair"] = pair + set_controller_config(context, config) + + # VAI AL CONNECTOR 2 (STEP 3) + context.user_data["arb_wizard_step"] = "connector_2" + + # Mostra la selezione del secondo exchange + await _arb_show_connector_step(update, context, exchange_num=2, target_message_id=message_id) + + # ========== GESTISCI INPUT MANUALE PER PAIR 2 ========== + elif step == "pair_2": + # message_id giΓ  definito all'inizio della funzione + pair = user_input.upper().strip().replace("/", "-").replace("_", "-") + connector = config.get("exchange_pair_2", {}).get("connector_name", "") + + # Validazione base + if "-" not in pair: + ep1 = config.get("exchange_pair_1", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_connector_2")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + ] + error_text = ( + r"*⚑ Arbitrage \- Step 4/6*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"2️⃣ `{escape_markdown_v2(connector)}`\n\n" + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. SOL\-USDT\)*" + "\n\n" + r"Type the pair again:" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + # Validazione sul secondo exchange (solo se CEX) + is_cex = "/" not in connector + if is_cex: + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = ( + await validate_trading_pair(context.user_data, client, connector, pair) + ) + + if not is_valid: + keyboard = [] + for sugg in suggestions[:4]: + keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:arb_pair_select:{sugg}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_connector_2")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + ep1 = config.get("exchange_pair_1", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + + error_text = ( + r"*⚑ Arbitrage \- Step 4/6*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"2️⃣ `{escape_markdown_v2(connector)}`\n\n" + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n" + r"*Did you mean?*" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + if correct_pair: + pair = correct_pair + + # Imposta la pair per il secondo exchange + if "exchange_pair_2" not in config: + config["exchange_pair_2"] = {} + config["exchange_pair_2"]["trading_pair"] = pair + + # Auto-set quote_conversion_asset + ep1 = config.get("exchange_pair_1", {}) + p1 = ep1.get("trading_pair", "") + if p1: + quote = p1.split("-")[1] if "-" in p1 else "USDT" + else: + quote = pair.split("-")[1] if "-" in pair else "USDT" + config["quote_conversion_asset"] = quote + + set_controller_config(context, config) + + # VAI ALL'AMOUNT (STEP 5) + context.user_data["arb_wizard_step"] = "total_amount_quote" + + # Mostra lo step dell'amount + await _arb_show_amount_step(update, context, target_message_id=message_id) + + # ========== GESTISCI INPUT PER TOTAL_AMOUNT_QUOTE ========== + elif step == "total_amount_quote": + message_id = context.user_data.get("arb_wizard_message_id") + wizard_chat_id = context.user_data.get("arb_wizard_chat_id", chat_id) + try: + # Rimuovi simboli di valuta e virgole + cleaned_input = user_input.strip().replace("$", "").replace(",", "") + + # Controlla se Γ¨ un numero valido + amount = float(cleaned_input) + + if amount <= 0: + raise ValueError("Amount must be positive") + + config["total_amount_quote"] = amount + set_controller_config(context, config) + context.user_data["arb_wizard_step"] = "final" + + # Mostra messaggio di caricamento + pair = config.get("exchange_pair_1", {}).get("trading_pair", "") + + if message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=r"*⚑ Arbitrage \- New Config*" + "\n\n" + f"⏳ Fetching prices for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + except Exception: + pass + else: + tmp_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=r"*⚑ Arbitrage \- New Config*" + "\n\n" + f"⏳ Fetching prices for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + context.user_data["arb_wizard_message_id"] = tmp_msg.message_id + + await _arb_show_final_step(update, context) + + except ValueError: + # Input non valido - mostra errore + ep1 = config.get("exchange_pair_1", {}) + ep2 = config.get("exchange_pair_2", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + c2 = ep2.get("connector_name", "") + p2 = ep2.get("trading_pair", "") + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:arb_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:arb_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:arb_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:arb_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:arb_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:arb_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_pair_2"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + error_text = ( + r"*⚑ Arbitrage \- Step 5/6*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"2️⃣ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n" + r"πŸ’° *Total Amount \(Quote\)*" + "\n" + r"⚠️ *Invalid amount\. Enter a positive number \(e\.g\. 500\)*" + "\n\n" + r"_Select or type an amount:_" + ) + + if message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + pass + else: + new_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["arb_wizard_message_id"] = new_msg.message_id + + # ========== GESTISCI INPUT PER FINAL (EDIT CAMPI) ========== + elif step == "final": + if "=" in user_input: + supported_fields = [ + "id", "total_amount_quote", "min_profitability", "delay_between_executors", + "max_executors_imbalance", "rate_connector", "quote_conversion_asset", + "exchange_pair_1_connector", "exchange_pair_1_pair", + "exchange_pair_2_connector", "exchange_pair_2_pair", + ] + + for line in user_input.strip().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() + + if field not in supported_fields: + continue + + try: + if field in ("min_profitability", "total_amount_quote"): + config[field] = float(value) + elif field in ("delay_between_executors", "max_executors_imbalance"): + config[field] = int(float(value)) + elif field.startswith("exchange_pair_"): + from .controllers.arbitrage_controller.config import apply_flat_fields + apply_flat_fields(config, {field: value}) + else: + config[field] = value + except Exception: + pass + + set_controller_config(context, config) + await _arb_show_final_step(update, context) + + except Exception as e: + logger.error(f"Arb wizard input error: {e}", exc_info=True) + +async def handle_arb_pair_select(update: Update, context: ContextTypes.DEFAULT_TYPE, trading_pair: str) -> None: + """Handle selection of a suggested trading pair in Arbitrage wizard""" + step = context.user_data.get("arb_wizard_step") + if step == "pair_1": + await handle_arb_wizard_pair_1(update, context, trading_pair) + elif step == "pair_2": + await handle_arb_wizard_pair_2(update, context, trading_pair) + +async def handle_arb_proceed_anyway(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle proceeding with DEX pair validation warning""" + pair = context.user_data.get("arb_pending_pair") + step = context.user_data.get("arb_wizard_step") + if step == "pair_1": + await handle_arb_wizard_pair_1(update, context, pair) + elif step == "pair_2": + await handle_arb_wizard_pair_2(update, context, pair) +# ============================================ +# XEMM MULTIPLE LEVELS WIZARD +# ============================================ +# Steps: maker_connector β†’ maker_pair β†’ taker_connector β†’ taker_pair β†’ amount β†’ final+save +# Prefisso handler: xemm_ + +async def show_new_xemm_multiple_levels_form(update, context) -> None: + """Start the XEMM wizard - Step 1: Maker Exchange (come arbitrage)""" + query = update.callback_query + chat_id = update.effective_chat.id + + for key in ["xemm_price_maker", "xemm_price_taker"]: + context.user_data.pop(key, None) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + 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}") + + init_new_controller_config(context, "xemm_multiple_levels") + context.user_data["bots_state"] = "xemm_wizard" + context.user_data["xemm_wizard_step"] = "maker_connector" # Step 1 + context.user_data["xemm_wizard_message_id"] = query.message.message_id + context.user_data["xemm_wizard_chat_id"] = query.message.chat_id + + await _xemm_show_connector_step(update, context, role="maker") + +async def _xemm_show_connector_step(update, context, role: str, target_message_id: int = None) -> None: + """Show connector selection for maker or taker""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + + # Usa target_message_id se fornito + use_message_id = target_message_id + if not query and not use_message_id: + logger.error("_xemm_show_connector_step called without callback_query and without target_message_id") + return + + if not query and use_message_id: + wizard_chat_id = context.user_data.get("xemm_wizard_chat_id", chat_id) + + is_maker = role == "maker" + step = 1 if is_maker else 3 + emoji = "🏭" if is_maker else "⚑" + role_label = "Maker (less liquid, limit orders)" if is_maker else "Taker (more liquid, hedge)" + # Show context for taker step + header = "" + if not is_maker: + mc = config.get("maker_connector", "") + mp = config.get("maker_trading_pair", "") + header = f"🏭 Maker: `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n\n" + + try: + client, server_name = await get_bots_client(chat_id, context.user_data) + cex_connectors = await get_available_cex_connectors( + context.user_data, client, server_name=server_name + ) + + # Taker can also be a DEX + dex_connectors = [] if is_maker else [ + "jupiter/router", "uniswap/ethereum", "uniswap/base", + "uniswap/arbitrum", "uniswap/bsc", "pancakeswap/bsc", + ] + + keyboard = [] + if cex_connectors: + if dex_connectors: + keyboard.append([InlineKeyboardButton("β€” CEX β€”", callback_data="bots:noop")]) + row = [] + for c in cex_connectors: + row.append(InlineKeyboardButton( + c, callback_data=f"bots:xemm_{role}_connector:{c}" + )) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + if dex_connectors: + keyboard.append([InlineKeyboardButton("β€” DEX (Gateway) β€”", callback_data="bots:noop")]) + row = [] + for d in dex_connectors: + row.append(InlineKeyboardButton( + d, callback_data=f"bots:xemm_{role}_connector:{d}" + )) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + back_cb = "bots:main_menu" if is_maker else "bots:xemm_back_to_pair" + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data=back_cb), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + message_text = ( + rf"*πŸ”„ XEMM Multi Levels \- Step {step}/6*" + "\n\n" + r"Places limit orders on maker exchange, hedges instantly on taker\." + "\n\n" + r"─────────────────────────" + "\n\n" + + header + + rf"*{emoji} Select {escape_markdown_v2(role_label)}:*" + ) + + # Invia/edita il messaggio + if query and query.message: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + elif use_message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"Error editing message in _xemm_show_connector_step: {e}") + new_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["xemm_wizard_message_id"] = new_msg.message_id + + except Exception as e: + logger.error(f"XEMM connector step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + error_text = format_error_message(f"Error: {str(e)}") + + if query and query.message: + await query.message.edit_text( + error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + elif use_message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + pass + +async def handle_xemm_maker_connector(update, context, connector: str) -> None: + """Handle Maker connector selection""" + config = get_controller_config(context) + config["maker_connector"] = connector + set_controller_config(context, config) + + # VAI AL PAIR DEL MAKER (Step 2) + context.user_data["xemm_wizard_step"] = "maker_pair" + await _xemm_show_pair_step(update, context, role="maker") + +async def handle_xemm_taker_connector(update, context, connector: str) -> None: + """Handle Taker connector selection""" + config = get_controller_config(context) + config["taker_connector"] = connector + set_controller_config(context, config) + + # VAI AL PAIR DEL TAKER (Step 4) + context.user_data["xemm_wizard_step"] = "taker_pair" + await _xemm_show_pair_step(update, context, role="taker") + +async def handle_xemm_wizard_pair(update, context, pair: str) -> None: + """Handle pair selection - applies to BOTH exchanges (come arbitrage)""" + query = update.callback_query + config = get_controller_config(context) + + # Applica lo stesso pair a entrambi (come arbitrage) + config["maker_trading_pair"] = pair + config["taker_trading_pair"] = pair + set_controller_config(context, config) + + # Vai al taker connector (Step 3) + context.user_data["xemm_wizard_step"] = "taker_connector" + await _xemm_show_connector_step(update, context, role="taker") + +async def _xemm_show_pair_step(update, context, role: str) -> None: + """Mostra l'input per la trading pair (Maker o Taker)""" + query = update.callback_query + config = get_controller_config(context) + + is_maker = (role == "maker") + connector = config.get("maker_connector" if is_maker else "taker_connector", "") + + context.user_data["bots_state"] = "xemm_wizard_input" + context.user_data["xemm_wizard_step"] = f"{role}_pair" + + step = 2 if is_maker else 4 + emoji = "🏭 Maker" if is_maker else "⚑ Taker" + + # Header con riepilogo passi precedenti + if not is_maker: + mc = config.get("maker_connector", "") + mp = config.get("maker_trading_pair", "") + header = f"🏭 `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n" \ + f"⚑ `{escape_markdown_v2(connector)}`\n\n" + else: + header = f"🏭 `{escape_markdown_v2(connector)}`\n\n" + + # Suggerimenti coppie recenti + existing_configs = context.user_data.get("controller_configs_list", []) + recent_pairs = [] + seen = set() + for cfg in reversed(existing_configs): + p = cfg.get(f"{role}_trading_pair", "") + if p and p not in seen: + seen.add(p); recent_pairs.append(p) + if len(recent_pairs) >= 6: break + + # Se Taker, suggerisci la stessa pair del Maker come prima opzione + if not is_maker: + mp = config.get("maker_trading_pair", "") + if mp and mp not in seen: recent_pairs.insert(0, mp) + + keyboard = [] + if recent_pairs: + row = [] + for p in recent_pairs[:6]: + row.append(InlineKeyboardButton(p, callback_data=f"bots:xemm_{role}_pair:{p}")) + if len(row) == 2: keyboard.append(row); row = [] + if row: keyboard.append(row) + + back_cb = "bots:xemm_back_to_maker_connector" if is_maker else "bots:xemm_back_to_taker_connector" + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data=back_cb), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + await query.message.edit_text( + rf"*πŸ”„ XEMM \- Step {step}/6*" + "\n\n" + header + + rf"*{emoji} Trading Pair:* " + "\n\n" + + r"_Esempio: BTC\-USDT_" + "\n\n" + + r"Seleziona o scrivi la coppia:", + parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard) + ) + +async def handle_xemm_maker_pair(update, context, pair: str) -> None: + """Handle maker pair selection""" + config = get_controller_config(context) + config["maker_trading_pair"] = pair.upper() + set_controller_config(context, config) + context.user_data["xemm_wizard_step"] = "taker_connector" + # Non serve target_message_id qui perchΓ© viene da callback + await _xemm_show_connector_step(update, context, role="taker") + + +async def handle_xemm_taker_pair(update, context, pair: str) -> None: + """Handle taker pair selection""" + config = get_controller_config(context) + config["taker_trading_pair"] = pair.upper() + set_controller_config(context, config) + context.user_data["xemm_wizard_step"] = "total_amount_quote" + # Non serve target_message_id perchΓ© viene da callback + await _xemm_show_amount_step(update, context) + +async def _show_xemm_pair_suggestions( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + input_pair: str, + error_msg: str, + suggestions: list, + connector: str, + role: str, # "maker" o "taker" +) -> None: + """Show trading pair suggestions when validation fails in XEMM wizard""" + message_id = context.user_data.get("xemm_wizard_message_id") + chat_id = context.user_data.get("xemm_wizard_chat_id") + wizard_chat_id = context.user_data.get("xemm_wizard_chat_id", update.effective_chat.id) + + # Memorizza la pair in sospeso per proceed_anyway + context.user_data["xemm_pending_pair"] = input_pair + # Memorizza anche lo step corrente + context.user_data["xemm_wizard_step"] = role + "_pair" + + # Build suggestion message + help_text = f"❌ *{escape_markdown_v2(error_msg)}*\n\n" + + if suggestions: + help_text += "πŸ’‘ *Did you mean:*\n" + else: + help_text += "_No similar pairs found\\._\n" + + # Build keyboard with suggestions + keyboard = [] + for pair in suggestions[:4]: + keyboard.append( + [ + InlineKeyboardButton( + f"πŸ“ˆ {pair}", callback_data=f"bots:xemm_{role}_pair:{pair}" + ) + ] + ) + + # Back button based on role + back_cb = "bots:xemm_back_to_maker_connector" if role == "maker" else "bots:xemm_back_to_taker_connector" + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data=back_cb), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + reply_markup = InlineKeyboardMarkup(keyboard) + + if message_id and chat_id: + try: + await context.bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=help_text, + parse_mode="MarkdownV2", + reply_markup=reply_markup, + ) + except Exception as e: + logger.debug(f"Could not update XEMM wizard message: {e}") + else: + await update.effective_chat.send_message( + help_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + + + +async def _xemm_show_amount_step(update, context, target_message_id: int = None) -> None: + """XEMM Step 5: Enter Total Amount""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + + # Usa target_message_id se fornito + use_message_id = target_message_id + if not query and not use_message_id: + logger.error("_xemm_show_amount_step called without callback_query and without target_message_id") + return + + if not query and use_message_id: + wizard_chat_id = context.user_data.get("xemm_wizard_chat_id", chat_id) + + mc = config.get("maker_connector", "") + mp = config.get("maker_trading_pair", "") + tc = config.get("taker_connector", "") + tp = config.get("taker_trading_pair", "") + + context.user_data["bots_state"] = "xemm_wizard_input" + context.user_data["xemm_wizard_step"] = "total_amount_quote" + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:xemm_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:xemm_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:xemm_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:xemm_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:xemm_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:xemm_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:xemm_back_to_taker_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + message_text = ( + r"*πŸ”„ XEMM Multi Levels \- Step 5/6*" + "\n\n" + f"🏭 `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n" + f"⚑ `{escape_markdown_v2(tc)}` \\| `{escape_markdown_v2(tp)}`\n\n" + r"πŸ’° *Total Amount \(Quote\)*" + "\n" + r"_50% allocated to buy side, 50% to sell side_\n" + r"_Select or type an amount:_" + ) + + # Invia/edita il messaggio + if query and query.message: + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + 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), + ) + elif use_message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"Error editing message in _xemm_show_amount_step: {e}") + # Fallback: invia un nuovo messaggio + new_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["xemm_wizard_message_id"] = new_msg.message_id + + +async def handle_xemm_wizard_amount(update, context, amount: float) -> None: + """Handle amount selection""" + query = update.callback_query + config = get_controller_config(context) + config["total_amount_quote"] = amount + set_controller_config(context, config) + + mp = config.get("maker_trading_pair", "") + await query.message.edit_text( + r"*πŸ”„ XEMM Multi Levels \- New Config*" + "\n\n" + f"⏳ Fetching prices for `{escape_markdown_v2(mp)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + context.user_data["xemm_wizard_step"] = "final" + await _xemm_show_final_step(update, context) + + +async def _xemm_show_final_step(update, context) -> None: + """XEMM Final Step: Show config + live spread + suggested levels""" + query = update.callback_query + if query: + msg = query.message + else: + msg = update.message + chat_id = update.effective_chat.id + config = get_controller_config(context) + + mc = config.get("maker_connector", "") + mp = config.get("maker_trading_pair", "") + tc = config.get("taker_connector", "") + tp = config.get("taker_trading_pair", "") + total_amount = config.get("total_amount_quote", 1000) + min_prof = config.get("min_profitability", 0.003) + max_prof = config.get("max_profitability", 0.01) + buy_levels = config.get("buy_levels_targets_amount", "0.003,10-0.006,20-0.009,30") + sell_levels = config.get("sell_levels_targets_amount", "0.003,10-0.006,20-0.009,30") + + # Generate ID if not set + if not config.get("id"): + existing_configs = context.user_data.get("controller_configs_list", []) + from .controllers.xemm_multiple_levels.config import generate_id as xemm_generate_id + config["id"] = xemm_generate_id(config, existing_configs) + set_controller_config(context, config) + + # Fetch live prices and calculate spread + spread_line = "" + try: + client, _ = await get_bots_client(chat_id, context.user_data) + + price_maker = None + price_taker = None + + if "/" not in mc: + price_maker = await fetch_current_price(client, mc, mp) + if price_maker: + context.user_data["xemm_price_maker"] = price_maker + + if "/" not in tc: + price_taker = await fetch_current_price(client, tc, tp) + if price_taker: + context.user_data["xemm_price_taker"] = price_taker + + if price_maker and price_taker: + spread_pct = abs(price_maker - price_taker) / min(price_maker, price_taker) + spread_pct_display = spread_pct * 100 + + # Auto-suggest levels based on observed spread + from .controllers.xemm_multiple_levels.config import suggest_levels_from_spread + suggested_levels = suggest_levels_from_spread(spread_pct, total_amount) + config["buy_levels_targets_amount"] = suggested_levels + config["sell_levels_targets_amount"] = suggested_levels + buy_levels = suggested_levels + sell_levels = suggested_levels + set_controller_config(context, config) + + spread_line = ( + "\n\n" + + r"πŸ“Š *Live Spread:*" + "\n" + f"🏭 `{escape_markdown_v2(mc)}`: `{price_maker:,.6g}`\n" + f"⚑ `{escape_markdown_v2(tc)}`: `{price_taker:,.6g}`\n" + f"Spread: `{spread_pct_display:.3f}%`\n" + r"_βœ… Levels auto\-suggested from spread_" + ) + elif price_maker: + spread_line = ( + "\n\n" + + r"πŸ“Š *Live Price \(maker\):*" + "\n" + f"🏭 `{escape_markdown_v2(mc)}`: `{price_maker:,.6g}`\n" + r"_⚠️ Taker price not available β€” using default levels_" + ) + + except Exception as e: + logger.warning(f"Could not fetch XEMM prices: {e}") + + context.user_data["bots_state"] = "xemm_wizard_input" + context.user_data["xemm_wizard_step"] = "final" + + keyboard = [ + [InlineKeyboardButton("πŸ’Ύ Save Config", callback_data="bots:xemm_save")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:xemm_back_to_amount"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + config_text = ( + r"*πŸ”„ XEMM Multi Levels \- Step 6/6 \(Final\)*" + "\n\n" + f"🏭 `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n" + f"⚑ `{escape_markdown_v2(tc)}` \\| `{escape_markdown_v2(tp)}`" + + spread_line + "\n\n" + f"`total_amount_quote={total_amount:.0f}`\n" + f"`buy_levels_targets_amount={escape_markdown_v2(str(buy_levels))}`\n" + f"`sell_levels_targets_amount={escape_markdown_v2(str(sell_levels))}`\n" + f"`min_profitability={min_prof}`\n" + f"`max_profitability={max_prof}`\n" + f"`max_executors_imbalance={config.get('max_executors_imbalance', 1)}`\n\n" + r"_Edit: `field=value`_" + "\n" + r"_Levels format: `profit,weight\-profit,weight`_" + ) + try: + await msg.edit_text( + config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + try: + await msg.delete() + except Exception: + pass + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["xemm_wizard_message_id"] = new_msg.message_id + context.user_data["xemm_wizard_chat_id"] = chat_id + + +async def handle_xemm_save(update, context) -> None: + """Save XEMM config""" + query = update.callback_query + config = get_controller_config(context) + config_id = config.get("id", "").replace("/", "-") + config["id"] = config_id # aggiorna anche nel config + chat_id = query.message.chat_id + + try: + await query.message.delete() + except Exception: + pass + + status_msg = await context.bot.send_message( + chat_id=chat_id, + text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + await client.controllers.create_or_update_controller_config(config_id, config) + + for key in ["xemm_wizard_step", "xemm_wizard_message_id", "xemm_wizard_chat_id", + "xemm_price_maker", "xemm_price_taker"]: + context.user_data.pop(key, None) + context.user_data.pop("bots_state", None) + + keyboard = [ + [InlineKeyboardButton("Create Another", callback_data="bots:new_xemm_multiple_levels")], + [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + "*Config Saved\\!*\n\n" + "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"XEMM save error: {e}", exc_info=True) + keyboard = [ + [InlineKeyboardButton("Try Again", callback_data="bots:xemm_save")], + [InlineKeyboardButton("Back", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + format_error_message(f"Failed to save: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +# Back handlers +async def handle_xemm_back_to_maker_connector(update, context) -> None: + context.user_data["xemm_wizard_step"] = "maker_connector" + await _xemm_show_connector_step(update, context, role="maker") + +async def handle_xemm_back_to_maker_pair(update, context) -> None: + context.user_data["xemm_wizard_step"] = "maker_trading_pair" + await _xemm_show_pair_step(update, context, role="maker") + +async def handle_xemm_back_to_taker_connector(update, context) -> None: + context.user_data["xemm_wizard_step"] = "taker_connector" + await _xemm_show_connector_step(update, context, role="taker") + +async def handle_xemm_back_to_taker_pair(update, context) -> None: + context.user_data["xemm_wizard_step"] = "taker_trading_pair" + await _xemm_show_pair_step(update, context, role="taker") + +async def handle_xemm_back_to_amount(update, context) -> None: + context.user_data["xemm_wizard_step"] = "total_amount_quote" + context.user_data.pop("xemm_price_maker", None) + context.user_data.pop("xemm_price_taker", None) + await _xemm_show_amount_step(update, context) + +async def process_xemm_wizard_input(update, context, user_input: str) -> None: + """Process text input during XEMM wizard""" + step = context.user_data.get("xemm_wizard_step") + chat_id = update.effective_chat.id + config = get_controller_config(context) + message_id = context.user_data.get("xemm_wizard_message_id") + wizard_chat_id = context.user_data.get("xemm_wizard_chat_id", chat_id) + + try: + try: + await update.message.delete() + except Exception: + pass + + # ========== GESTISCI INPUT MANUALE PER MAKER PAIR ========== + if step == "maker_pair": + pair = user_input.upper().strip().replace("/", "-").replace("_", "-") + connector = config.get("maker_connector", "") + + # Validazione base + if "-" not in pair: + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data="bots:xemm_back_to_maker_connector")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + ] + error_text = ( + r"*πŸ”„ XEMM Multi Levels \- Step 2*" + "\n\n" + f"🏭 `{escape_markdown_v2(connector)}`\n\n" + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n" + r"Type the pair again:" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + # Validazione sul maker exchange (solo se CEX) + is_cex = "/" not in connector + if is_cex: + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = ( + await validate_trading_pair(context.user_data, client, connector, pair) + ) + + if not is_valid: + await _show_xemm_pair_suggestions( + update, context, pair, error_msg, suggestions, connector, "maker" + ) + return + + if correct_pair: + pair = correct_pair + else: + # DEX - avvertimento ma permetti di continuare + context.user_data["xemm_pending_pair"] = pair + warning_text = ( + r"*πŸ”„ XEMM Multi Levels \- Step 2*" + "\n\n" + f"🏭 `{escape_markdown_v2(connector)}` \\| `{escape_markdown_v2(pair)}`\n\n" + r"⚠️ *DEX pair validation not available*" + "\n\n" + r"_The pair may not exist on the DEX\. Proceed with caution\._" + ) + keyboard = [[InlineKeyboardButton("βœ… Proceed Anyway", callback_data="bots:xemm_proceed_anyway")]] + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=warning_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + # Imposta la pair per il maker exchange + config["maker_trading_pair"] = pair + set_controller_config(context, config) + + # VAI AL TAKER CONNECTOR (Step 3) + context.user_data["xemm_wizard_step"] = "taker_connector" + + # Mostra la selezione del taker exchange + await _xemm_show_connector_step(update, context, role="taker", target_message_id=message_id) + + # ========== GESTISCI INPUT MANUALE PER TAKER PAIR ========== + elif step == "taker_pair": + pair = user_input.upper().strip().replace("/", "-").replace("_", "-") + connector = config.get("taker_connector", "") + + # Validazione base + if "-" not in pair: + mc = config.get("maker_connector", "") + mp = config.get("maker_trading_pair", "") + + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data="bots:xemm_back_to_taker_connector")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + ] + error_text = ( + r"*πŸ”„ XEMM Multi Levels \- Step 4*" + "\n\n" + f"🏭 `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n" + f"⚑ `{escape_markdown_v2(connector)}`\n\n" + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n" + r"Type the pair again:" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + # Validazione sul taker exchange (solo se CEX) + is_cex = "/" not in connector + if is_cex: + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = ( + await validate_trading_pair(context.user_data, client, connector, pair) + ) + + if not is_valid: + await _show_xemm_pair_suggestions( + update, context, pair, error_msg, suggestions, connector, "taker" + ) + return + + if correct_pair: + pair = correct_pair + else: + # DEX - avvertimento ma permetti di continuare + context.user_data["xemm_pending_pair"] = pair + mc = config.get("maker_connector", "") + mp = config.get("maker_trading_pair", "") + warning_text = ( + r"*πŸ”„ XEMM Multi Levels \- Step 4*" + "\n\n" + f"🏭 `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n" + f"⚑ `{escape_markdown_v2(connector)}` \\| `{escape_markdown_v2(pair)}`\n\n" + r"⚠️ *DEX pair validation not available*" + "\n\n" + r"_The pair may not exist on the DEX\. Proceed with caution\._" + ) + keyboard = [[InlineKeyboardButton("βœ… Proceed Anyway", callback_data="bots:xemm_proceed_anyway")]] + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=warning_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + # Imposta la pair per il taker exchange + config["taker_trading_pair"] = pair + set_controller_config(context, config) + + # VAI ALL'AMOUNT (Step 5) + context.user_data["xemm_wizard_step"] = "total_amount_quote" + + # Mostra lo step dell'amount + await _xemm_show_amount_step(update, context, target_message_id=message_id) + elif step == "taker_connector": + # Questo step Γ¨ gestito esclusivamente dai callback button + pass + # ========== TOTAL AMOUNT QUOTE (ispirato a arbitrage) ========== + elif step == "total_amount_quote": + try: + # Rimuovi simboli di valuta e virgole + cleaned_input = user_input.strip().replace("$", "").replace(",", "") + + # Controlla se Γ¨ un numero valido + amount = float(cleaned_input) + + if amount <= 0: + raise ValueError("Amount must be positive") + + config["total_amount_quote"] = amount + set_controller_config(context, config) + context.user_data["xemm_wizard_step"] = "final" + + # Mostra messaggio di caricamento (come arbitrage) + wizard_chat_id = context.user_data.get("xemm_wizard_chat_id", chat_id) + mp = config.get("maker_trading_pair", "") + + # Crea un messaggio temporaneo per il caricamento + tmp_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=r"*πŸ”„ XEMM Multi Levels \- New Config*" + "\n\n" + f"⏳ Fetching prices for `{escape_markdown_v2(mp)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + context.user_data["xemm_wizard_message_id"] = tmp_msg.message_id + await _xemm_show_final_step(update, context) + + except ValueError: + # Input non valido - mostra errore (come arbitrage) + mc = config.get("maker_connector", "") + mp = config.get("maker_trading_pair", "") + tc = config.get("taker_connector", "") + tp = config.get("taker_trading_pair", "") + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:xemm_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:xemm_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:xemm_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:xemm_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:xemm_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:xemm_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:xemm_back_to_taker_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + error_text = ( + r"*πŸ”„ XEMM Multi Levels \- Step 5/6*" + "\n\n" + f"🏭 `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n" + f"⚑ `{escape_markdown_v2(tc)}` \\| `{escape_markdown_v2(tp)}`\n\n" + r"πŸ’° *Total Amount \(Quote\)*" + "\n" + r"⚠️ *Invalid amount\. Enter a positive number \(e\.g\. 500\)*" + "\n\n" + r"_Select or type an amount:_" + ) + + if message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + pass + else: + # Se non c'Γ¨ message_id, invia un nuovo messaggio + new_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["xemm_wizard_message_id"] = new_msg.message_id + + # ========== FINAL (edit campi) ========== + elif step == "final": + if "=" in user_input: + for line in user_input.strip().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() + try: + if field in ("total_amount_quote", "min_profitability", "max_profitability"): + config[field] = float(value) + elif field == "max_executors_imbalance": + config[field] = int(float(value)) + elif field in ("buy_levels_targets_amount", "sell_levels_targets_amount"): + # Validazione formato (es. "0.003,10-0.006,20") + parts = value.split("-") + valid = True + for part in parts: + vals = part.split(",") + if len(vals) != 2: + valid = False + break + try: + float(vals[0]) + float(vals[1]) + except ValueError: + valid = False + break + if valid: + config[field] = value + else: + config[field] = value + except Exception: + pass + set_controller_config(context, config) + await _xemm_show_final_step(update, context) + + except Exception as e: + logger.error(f"XEMM wizard input error: {e}", exc_info=True) + +async def handle_xemm_back_to_pair(update, context) -> None: + """Go back to trading pair selection""" + context.user_data["xemm_wizard_step"] = "trading_pair" + await _xemm_show_pair_step(update, context) + + +async def handle_xemm_proceed_anyway(update, context) -> None: + """Handle proceeding with DEX pair validation warning""" + query = update.callback_query + pair = context.user_data.get("xemm_pending_pair") + step = context.user_data.get("xemm_wizard_step") + config = get_controller_config(context) + + if not pair: + await query.answer("No pending pair found", show_alert=True) + return + + if step == "maker_pair": + # Imposta la pair per il maker exchange + config["maker_trading_pair"] = pair + set_controller_config(context, config) + # Vai al taker connector (Step 3) + context.user_data["xemm_wizard_step"] = "taker_connector" + await _xemm_show_connector_step(update, context, role="taker") + + elif step == "taker_pair": + # Imposta la pair per il taker exchange + config["taker_trading_pair"] = pair + set_controller_config(context, config) + # Vai all'amount (Step 5) + context.user_data["xemm_wizard_step"] = "total_amount_quote" + await _xemm_show_amount_step(update, context) + + else: + # Fallback: vai all'amount (comportamento originale) + context.user_data["xemm_wizard_step"] = "total_amount_quote" + + mc = config.get("maker_connector", "") + mp = config.get("maker_trading_pair", "") + tc = config.get("taker_connector", "") + tp = config.get("taker_trading_pair", "") + + # Se non c'Γ¨ maker_pair, usa la pair in sospeso + if not mp and pair: + mp = pair + config["maker_trading_pair"] = pair + set_controller_config(context, config) + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:xemm_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:xemm_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:xemm_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:xemm_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:xemm_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:xemm_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:xemm_back_to_taker_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + amount_text = ( + r"*πŸ”„ XEMM Multi Levels \- Step 5/6*" + "\n\n" + f"🏭 `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n" + f"⚑ `{escape_markdown_v2(tc)}` \\| `{escape_markdown_v2(tp)}`\n\n" + r"πŸ’° *Total Amount \(USDT\)*" + "\n" + r"_Select or type an amount:_" + ) + + await query.message.edit_text( + amount_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) +# ============================================ +# MACD BB V1 WIZARD +# ============================================ +# Steps: connector β†’ pair β†’ (leverage) β†’ amount β†’ interval+chart β†’ final +# Prefisso handler: macdbb_ + +async def show_new_macd_bb_v1_form(update, context) -> None: + """Start the MACD BB V1 wizard - Step 1: Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + # Clear cached data + for key in ["macdbb_current_price", "macdbb_candles", "macdbb_candles_interval", + "macdbb_chart_interval", "macdbb_trading_rules"]: + context.user_data.pop(key, None) + + # Fetch existing configs for sequence numbering + try: + client, _ = await get_bots_client(chat_id, context.user_data) + 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}") + + init_new_controller_config(context, "macd_bb_v1") + context.user_data["bots_state"] = "macdbb_wizard" + context.user_data["macdbb_wizard_step"] = "connector_name" + context.user_data["macdbb_wizard_message_id"] = query.message.message_id + context.user_data["macdbb_wizard_chat_id"] = query.message.chat_id + + await _macdbb_show_connector_step(update, context) + + +async def _macdbb_show_connector_step(update, context) -> None: + """MACD BB Step 1: Select Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + try: + client, server_name = await get_bots_client(chat_id, context.user_data) + cex_connectors = await get_available_cex_connectors( + context.user_data, client, server_name=server_name + ) + + if not cex_connectors: + keyboard = [ + [InlineKeyboardButton("πŸ”‘ Configure API Keys", callback_data="config_api_keys")], + [InlineKeyboardButton("Β« Back", callback_data="bots:main_menu")], + ] + await query.message.edit_text( + r"*πŸ“Š MACD BB V1 \- New Config*" + "\n\n" + r"⚠️ No CEX connectors available\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + keyboard = [] + row = [] + for connector in cex_connectors: + row.append(InlineKeyboardButton( + f"🏦 {connector}", callback_data=f"bots:macdbb_connector:{connector}" + )) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + await query.message.edit_text( + r"*πŸ“Š MACD BB V1*" + "\n\n" + r"Combines Bollinger Bands with MACD confirmation\. " + r"Enters LONG when BBP is low AND MACD histogram is rising\. " + r"Enters SHORT when BBP is high AND MACD histogram is falling\." + "\n\n" + r"─────────────────────────" + "\n\n" + r"*Step 1: Select Exchange*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + disable_web_page_preview=True, + ) + + except Exception as e: + logger.error(f"MACD BB connector step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + await query.message.edit_text( + format_error_message(f"Error: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_macdbb_wizard_connector(update, context, connector: str) -> None: + """Handle connector selection""" + config = get_controller_config(context) + config["connector_name"] = connector + config["candles_connector"] = connector # Auto-set candles connector = same exchange + set_controller_config(context, config) + context.user_data["macdbb_wizard_step"] = "trading_pair" + await _macdbb_show_pair_step(update, context) + +async def _macdbb_show_pair_step(update, context) -> None: + """MACD BB Step 2: Select Trading Pair""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + + context.user_data["bots_state"] = "macdbb_wizard_input" + context.user_data["macdbb_wizard_step"] = "trading_pair" + + # Recent pairs from existing configs + 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:macdbb_pair:{pair}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 6 if is_perp else 4 + current_step = 2 + + message_text = ( + rf"*πŸ“Š MACD BB V1 \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n" + r"πŸ”— *Trading Pair*" + "\n\n" + r"Select a recent pair or type a new one:" + ) + + # ========== SALVA MESSAGE_ID ========== + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["macdbb_wizard_message_id"] = query.message.message_id + except Exception: + try: + await query.message.delete() + except Exception: + pass + new_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["macdbb_wizard_message_id"] = new_msg.message_id + context.user_data["macdbb_wizard_chat_id"] = query.message.chat_id + +async def handle_macdbb_wizard_pair(update, context, pair: str) -> None: + """Handle pair selection via button""" + config = get_controller_config(context) + config["trading_pair"] = pair + config["candles_trading_pair"] = pair + set_controller_config(context, config) + + connector = config.get("connector_name", "").lower() + is_perp = "_perpetual" in connector or "_margin" in connector + + if is_perp: + context.user_data["macdbb_wizard_step"] = "leverage" + await _macdbb_show_leverage_step(update, context) + else: + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + set_controller_config(context, config) + context.user_data["macdbb_wizard_step"] = "total_amount_quote" + await _macdbb_show_amount_step(update, context) + +async def _macdbb_show_leverage_step(update, context) -> None: + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + + context.user_data["bots_state"] = "macdbb_wizard_input" + context.user_data["macdbb_wizard_step"] = "leverage" + + keyboard = [ + [ + InlineKeyboardButton("1x", callback_data="bots:macdbb_leverage:1"), + InlineKeyboardButton("5x", callback_data="bots:macdbb_leverage:5"), + InlineKeyboardButton("10x", callback_data="bots:macdbb_leverage:10"), + ], + [ + InlineKeyboardButton("20x", callback_data="bots:macdbb_leverage:20"), + InlineKeyboardButton("50x", callback_data="bots:macdbb_leverage:50"), + InlineKeyboardButton("75x", callback_data="bots:macdbb_leverage:75"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + # ========== SALVA message_id PER ERROR HANDLING ========== + context.user_data["macdbb_wizard_message_id"] = query.message.message_id + context.user_data["macdbb_wizard_chat_id"] = query.message.chat_id + # ======================================================== + + await query.message.edit_text( + r"*πŸ“Š MACD BB V1 \- Step 3/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n\n" + r"⚑ *Select Leverage*" + "\n" + r"_Or type a value \(e\.g\. 20\)_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_macdbb_wizard_leverage(update, context, leverage: int) -> None: + config = get_controller_config(context) + config["leverage"] = leverage + set_controller_config(context, config) + context.user_data["macdbb_wizard_step"] = "position_mode" + await _macdbb_show_position_mode_step(update, context) + +async def _macdbb_show_position_mode_step(update, context) -> None: + """MACD BB Wizard Step: Select Position Mode (Perpetual/Margin only)""" + query = update.callback_query + config = get_controller_config(context) + connector = escape_markdown_v2(config.get("connector_name", "")) + pair = config.get("trading_pair", "") + leverage = config.get("leverage", 1) + + context.user_data["macdbb_wizard_message_id"] = query.message.message_id + context.user_data["macdbb_wizard_chat_id"] = query.message.chat_id + + keyboard = [ + [ + InlineKeyboardButton("πŸ”’ ONEWAY", callback_data="bots:macdbb_position_mode:ONEWAY"), + InlineKeyboardButton("πŸ”„ HEDGE", callback_data="bots:macdbb_position_mode:HEDGE"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_leverage"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + + # CORREZIONE: escape di tutti i caratteri speciali MarkdownV2 + # I caratteri speciali sono: _ * [ ] ( ) ~ ` > # + - = | { } . ! + escaped_connector = escape_markdown_v2(connector) + escaped_pair = escape_markdown_v2(pair) + + await query.message.edit_text( + f"*πŸ“Š MACD BB V1 \\- Step 4/6*\n\n" + f"🏦 `{escaped_connector}` \\| πŸ”— `{escaped_pair}` \\| ⚑ `{leverage}x`\n\n" + r"🎯 *Position Mode*" + "\n\n" + r"β€’ *HEDGE*: Can hold both long and short positions simultaneously" + "\n" + r"β€’ *ONEWAY*: Can only hold positions in one direction \(long OR short\)", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_macdbb_position_mode(update, context, mode: str) -> None: + config = get_controller_config(context) + config["position_mode"] = mode + set_controller_config(context, config) + context.user_data["macdbb_wizard_step"] = "total_amount_quote" + await _macdbb_show_amount_step(update, context) + +async def _macdbb_show_amount_step(update, context) -> None: + """MACD BB Step: Enter Total Amount""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + leverage = config.get("leverage", 1) + pos_mode = config.get("position_mode", "HEDGE") + + context.user_data["bots_state"] = "macdbb_wizard_input" + context.user_data["macdbb_wizard_step"] = "total_amount_quote" + is_perp = "_perpetual" in connector.lower() or "_margin" in connector.lower() + total_steps = 6 if is_perp else 4 + current_step = 5 if is_perp else 3 + + # Fetch balance + balance_text = "" + try: + client, _ = await get_bots_client(chat_id, context.user_data) + balances = await get_cex_balances( + context.user_data, client, "master_account", ttl=30 + ) + + # Flexible matching come in Grid Strike + 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", "") + if bal_lower == connector_lower or bal_base == connector_base: + connector_balances = bal_list + break + + if connector_balances: + relevant_balances = [] + quote = pair.split("-")[1] if "-" in pair else "USDT" + for bal in connector_balances: + token = bal.get("token", bal.get("asset", "")) + available = bal.get("units", bal.get("available_balance", bal.get("free", 0))) + if token and token.upper() == quote.upper(): + try: + available_float = float(available) + if available_float > 0: + balance_text = f"\n\nπŸ’° Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`" + break + except (ValueError, TypeError): + continue + except Exception as e: + logger.warning(f"Could not fetch balances for MacdBB amount step: {e}") + + # Gestione dinamica del tasto Back + back_callback = "bots:macdbb_back_to_position_mode" if is_perp else "bots:macdbb_back_to_pair" + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:macdbb_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:macdbb_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:macdbb_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:macdbb_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:macdbb_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:macdbb_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data=back_callback), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + if is_perp: + header_info += f" \\| ⚑ `{leverage}x` \\| 🎯 `{pos_mode}`" + + message_text = ( + rf"*πŸ“Š MACD BB V1 \- Step {current_step}/{total_steps}*" + "\n\n" + + header_info + balance_text + "\n\n" + r"πŸ’° *Total Amount \(USDT\)*" + "\n" + r"_Select or type an amount:_" + ) + + # ========== SALVA MESSAGE_ID ========== + target_chat_id = chat_id + if query and query.message: + target_chat_id = query.message.chat_id + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["macdbb_wizard_message_id"] = query.message.message_id + return + except Exception: + try: + await query.message.delete() + except Exception: + pass + + new_msg = await context.bot.send_message( + chat_id=target_chat_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["macdbb_wizard_message_id"] = new_msg.message_id + context.user_data["macdbb_wizard_chat_id"] = target_chat_id + +async def handle_macdbb_wizard_amount(update, context, amount: float) -> None: + """Handle amount selection""" + query = update.callback_query + config = get_controller_config(context) + config["total_amount_quote"] = amount + set_controller_config(context, config) + + pair = config.get("trading_pair", "") + await query.message.edit_text( + r"*πŸ“Š MACD BB V1 \- New Config*" + "\n\n" + f"⏳ *Loading chart for* `{escape_markdown_v2(pair)}`\\.\\.\\. " + "\n\n" + r"_Fetching market data\.\.\._", + parse_mode="MarkdownV2", + ) + + context.user_data["macdbb_wizard_step"] = "final" + await _macdbb_show_final_step(update, context) + +async def _macdbb_show_final_step(update, context, interval: str = None) -> None: + """MACD BB Final Step: Chart + Config Summary + Analysis""" + query = update.callback_query + msg = query.message if query else update.message + chat_id = update.effective_chat.id + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + total_amount = config.get("total_amount_quote", 1000) + leverage = config.get("leverage", 1) + bb_length = config.get("bb_length", 100) + macd_fast = config.get("macd_fast", 21) + macd_slow = config.get("macd_slow", 42) + macd_signal_period = config.get("macd_signal", 9) + + if interval is None: + interval = context.user_data.get("macdbb_chart_interval", config.get("interval", "5m")) + context.user_data["macdbb_chart_interval"] = interval + config["interval"] = interval + set_controller_config(context, config) + + current_price = context.user_data.get("macdbb_current_price") + candles = context.user_data.get("macdbb_candles") + + try: + cached_interval = context.user_data.get("macdbb_candles_interval", interval) + if not current_price or interval != cached_interval: + try: + await msg.edit_text( + r"*πŸ“Š MACD BB V1 \- New Config*" + "\n\n" + f"⏳ Fetching market data for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + except Exception: + pass + client, _ = await get_bots_client(chat_id, context.user_data) + candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "") + current_price = await fetch_current_price(client, connector, pair) + + if current_price: + # CORREZIONE QUI - usa macdbb_current_price + context.user_data["macdbb_current_price"] = current_price + candles = await fetch_candles( + client, candles_connector, pair, interval=interval, max_records=420 + ) + context.user_data["macdbb_candles"] = candles + context.user_data["macdbb_candles_interval"] = interval + + if not current_price: + keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]] + await msg.edit_text( + r"*❌ Error*" + "\n\n" + f"Could not fetch price for `{escape_markdown_v2(pair)}`\\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + candles_list = candles.get("data", []) if isinstance(candles, dict) else (candles or []) + + if candles_list: + last_close = candles_list[-1].get("close") or candles_list[-1].get("c") + if last_close: + current_price = float(last_close) + context.user_data["macdbb_current_price"] = current_price + + # Generate config ID if not set + if not config.get("id"): + existing_configs = context.user_data.get("controller_configs_list", []) + from .controllers.macd_bb_v1.config import generate_id as macdbb_generate_id + config["id"] = macdbb_generate_id(config, existing_configs) + + from .controllers.macd_bb_v1.analysis import analyze_candles_for_macd_bb, format_macd_bb_analysis + analysis = analyze_candles_for_macd_bb(candles_list, bb_length=config.get("bb_length", 100), bb_std=config.get("bb_std", 2.0), macd_fast=macd_fast, macd_slow=macd_slow, macd_signal=macd_signal_period) + + # Auto-save suggested thresholds + if config.get("bb_long_threshold", 0.0) == 0.0: + config["bb_long_threshold"] = analysis["suggested_long_threshold"] + if config.get("bb_short_threshold", 1.0) == 1.0: + config["bb_short_threshold"] = analysis["suggested_short_threshold"] + set_controller_config(context, config) + + position_mode = config.get("position_mode", "HEDGE") + stop_loss = config.get("stop_loss", 0.05) + take_profit = config.get("take_profit", 0.03) + max_exec = config.get("max_executors_per_side", 1) + cooldown = config.get("cooldown_time", 60) + bb_std = config.get("bb_std", 2.0) + bb_long = config.get("bb_long_threshold", 0.0) + bb_short = config.get("bb_short_threshold", 1.0) + ts = config.get("trailing_stop", {}) or {} + ts_act = ts.get("activation_price", 0.015) if isinstance(ts, dict) else 0.015 + ts_delta = ts.get("trailing_delta", 0.005) if isinstance(ts, dict) else 0.005 + + context.user_data["bots_state"] = "macdbb_wizard_input" + context.user_data["macdbb_wizard_step"] = "final" + + interval_options = ["1m", "5m", "15m", "1h", "8h"] + interval_row = [ + InlineKeyboardButton( + f"βœ“ {opt}" if opt == interval else opt, + callback_data=f"bots:macdbb_interval:{opt}" + ) + for opt in interval_options + ] + + # ========== SALVA ANALISI PER I BOTTONI STRATEGIA ========== + context.user_data["macdbb_analysis"] = analysis + strategy_row = [ + InlineKeyboardButton("🎯 Scalp", callback_data="bots:macdbb_set_strat:scalping"), + InlineKeyboardButton("βš–οΈ Swing", callback_data="bots:macdbb_set_strat:swing"), + InlineKeyboardButton("πŸ€– Auto", callback_data="bots:macdbb_set_strat:auto"), + ] + + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + final_step = 6 if is_perp else 4 + + keyboard = [ + interval_row, + strategy_row, # <--- AGGIUNGI QUESTA RIGA + [InlineKeyboardButton("πŸ’Ύ Save Config", callback_data="bots:macdbb_save")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_amount"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + config_text = ( + rf"*πŸ“Š MACD BB V1 \- Step {final_step}/{final_step} \(Final\)*" + "\n\n" + f"*{escape_markdown_v2(pair)}*\n" + f"Price: `{current_price:,.6g}` \\| BB: `{bb_length}` \\| MACD: `{macd_fast}/{macd_slow}/{macd_signal_period}` \\| Interval: `{interval}`\n\n" + f"`connector_name={escape_markdown_v2(connector)}`\n" + f"`trading_pair={escape_markdown_v2(pair)}`\n" + f"`total_amount_quote={total_amount:.0f}`\n" + f"`leverage={leverage}`\n" + f"`position_mode={escape_markdown_v2(position_mode)}`\n" + f"`max_executors_per_side={max_exec}`\n" + f"`cooldown_time={cooldown}`\n" + f"`stop_loss={stop_loss}`\n" + f"`take_profit={take_profit}`\n" + f"`trailing_stop_activation={ts_act}`\n" + f"`trailing_stop_delta={ts_delta}`\n" + f"`interval={interval}`\n" + f"`bb_length={bb_length}`\n" + f"`bb_std={bb_std}`\n" + f"`bb_long_threshold={bb_long}`\n" + f"`bb_short_threshold={bb_short}`\n" + f"`macd_fast={macd_fast}`\n" + f"`macd_slow={macd_slow}`\n" + f"`macd_signal={macd_signal_period}`\n\n" + r"_Edit: `field=value`_" + ) + + # ========== ANALYSIS TEXT - USARE TRIPLI BACKTICK ========== + analysis_text = format_macd_bb_analysis(analysis) + config_text += "\n\n```\n" + analysis_text + "\n```" + # ========================================================== + + # ========== FIX CAPTION TOO LONG ========== + MAX_CAPTION_LEN = 950 + if len(config_text) > MAX_CAPTION_LEN: + truncation_note = "\n\n_...truncated due to length limit. Edit via Configs menu._" + max_allowed = MAX_CAPTION_LEN - len(truncation_note) + config_text = config_text[:max_allowed] + truncation_note + # ========================================== + + + if candles_list: + from .controllers.macd_bb_v1.chart import generate_chart as macdbb_chart + chart_bytes = macdbb_chart(config, candles_list, current_price) + + try: + await msg.delete() + except Exception: + pass + + new_msg = await context.bot.send_photo( + chat_id=chat_id, + photo=chart_bytes, + caption=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["macdbb_wizard_message_id"] = new_msg.message_id + context.user_data["macdbb_wizard_chat_id"] = chat_id + else: + try: + await msg.edit_text( + text=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["macdbb_wizard_message_id"] = new_msg.message_id + + except Exception as e: + logger.error(f"MACD BB final step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + try: + await msg.edit_text( + format_error_message(f"Error: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + pass + + +async def handle_macdbb_interval_change(update, context, interval: str) -> None: + """Change chart interval""" + context.user_data["macdbb_candles"] = None + context.user_data["macdbb_candles_interval"] = None + await _macdbb_show_final_step(update, context, interval=interval) + +async def handle_macdbb_set_strategy(update, context, strat_key: str) -> None: + """Handle MACD BB strategy selection from final step buttons.""" + query = update.callback_query + config = get_controller_config(context) + + analysis = context.user_data.get("macdbb_analysis", {}) + natr = analysis.get("natr", 0.01) + + from .controllers.macd_bb_v1.analysis import get_macd_bb_strategy_suggestions + strats = get_macd_bb_strategy_suggestions(natr, analysis) + + if strat_key in strats: + selected = strats[strat_key] + + # Aggiorna i parametri + config["bb_length"] = selected.get("bb_length", 100) + config["bb_std"] = selected.get("bb_std", 2.0) + config["macd_fast"] = selected.get("macd_fast", 21) + config["macd_slow"] = selected.get("macd_slow", 42) + config["macd_signal"] = selected.get("macd_signal", 9) + config["bb_long_threshold"] = selected.get("bb_long_threshold", 0.0) + config["bb_short_threshold"] = selected.get("bb_short_threshold", 1.0) + config["take_profit"] = selected.get("take_profit", 0.03) + config["stop_loss"] = selected.get("stop_loss", 0.05) + + # ========== AGGIUNGI TRAILING STOP ========== + if "trailing_stop_activation" in selected: + if not isinstance(config.get("trailing_stop"), dict): + config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005} + config["trailing_stop"]["activation_price"] = selected["trailing_stop_activation"] + config["trailing_stop"]["trailing_delta"] = selected["trailing_stop_delta"] + # =========================================== + + set_controller_config(context, config) + + vol_regime = selected.get("volatility_regime", "moderate") + await query.answer(f"βœ… {selected['label']} applicata (vol: {vol_regime})") + + return await _macdbb_show_final_step(update, context) + +async def handle_macdbb_save(update, context) -> None: + """Save MACD BB V1 configuration""" + query = update.callback_query + config = get_controller_config(context) + + # ========== PULISCI I CAMPI NON NECESSARI ========== + config.pop("candles_config", None) + config.pop("manual_kill_switch", None) + # =================================================== + + config_id = config.get("id", "") + chat_id = query.message.chat_id + + try: + await query.message.delete() + except Exception: + pass + + status_msg = await context.bot.send_message( + chat_id=chat_id, + text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + await client.controllers.create_or_update_controller_config(config_id, config) + + for key in ["macdbb_wizard_step", "macdbb_wizard_message_id", "macdbb_wizard_chat_id", + "macdbb_current_price", "macdbb_candles", "macdbb_candles_interval", + "macdbb_chart_interval"]: + context.user_data.pop(key, None) + context.user_data.pop("bots_state", None) + + keyboard = [ + [InlineKeyboardButton("Create Another", callback_data="bots:new_macd_bb_v1")], + [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + "*Config Saved\\!*\n\n" + "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"MACD BB save error: {e}", exc_info=True) + keyboard = [ + [InlineKeyboardButton("Try Again", callback_data="bots:macdbb_save")], + [InlineKeyboardButton("Back", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + format_error_message(f"Failed to save: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +# Back handlers +async def handle_macdbb_back_to_connector(update, context) -> None: + context.user_data["macdbb_wizard_step"] = "connector_name" + await _macdbb_show_connector_step(update, context) + + +async def handle_macdbb_back_to_pair(update, context) -> None: + context.user_data["macdbb_wizard_step"] = "trading_pair" + await _macdbb_show_pair_step(update, context) + + +async def handle_macdbb_back_to_leverage(update, context) -> None: + config = get_controller_config(context) + connector = config.get("connector_name", "") + # Usa la stessa logica degli altri wizard + is_perp = "_perpetual" in connector or "_margin" in connector + if is_perp: + context.user_data["macdbb_wizard_step"] = "leverage" + await _macdbb_show_leverage_step(update, context) + else: + await handle_macdbb_back_to_pair(update, context) + +async def handle_macdbb_back_to_amount(update, context) -> None: + """Go back to amount step""" + # Non cancellare il messaggio! + # Lascia che _macdbb_show_amount_step lo editi + + context.user_data["macdbb_wizard_step"] = "total_amount_quote" + context.user_data.pop("macdbb_current_price", None) + context.user_data.pop("macdbb_candles", None) + context.user_data.pop("macdbb_candles_interval", None) + + await _macdbb_show_amount_step(update, context) + +async def handle_macdbb_back_to_position_mode(update, context) -> None: + """Go back to position mode step""" + context.user_data["macdbb_wizard_step"] = "position_mode" + await _macdbb_show_position_mode_step(update, context) + +async def handle_macdbb_pair_select( + update: Update, context: ContextTypes.DEFAULT_TYPE, trading_pair: str +) -> None: + """Handle selection of a suggested trading pair in MACD BB V1 wizard""" + query = update.callback_query + config = get_controller_config(context) + chat_id = update.effective_chat.id + + # Clear old market data + for key in ["macdbb_current_price", "macdbb_candles", "macdbb_candles_interval"]: + context.user_data.pop(key, None) + + config["trading_pair"] = trading_pair + config["candles_trading_pair"] = trading_pair + set_controller_config(context, config) + + # Move to next step based on connector type + connector = config.get("connector_name", "") + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + if is_perp: + context.user_data["macdbb_wizard_step"] = "leverage" + await _macdbb_show_leverage_step(update, context) + else: + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + set_controller_config(context, config) + context.user_data["macdbb_wizard_step"] = "total_amount_quote" + await _macdbb_show_amount_step(update, context) + +async def process_macdbb_wizard_input(update, context, user_input: str) -> None: + """Process text input during MACD BB V1 wizard""" + step = context.user_data.get("macdbb_wizard_step") + chat_id = update.effective_chat.id + config = get_controller_config(context) + message_id = context.user_data.get("macdbb_wizard_message_id") + wizard_chat_id = context.user_data.get("macdbb_wizard_chat_id", chat_id) + + try: + try: + await update.message.delete() + except Exception: + pass + + # ========== GESTISCI INPUT MANUALE PER TRADING PAIR ========== + if step == "trading_pair": + pair = user_input.upper().strip().replace("/", "-").replace("_", "-") + connector = config.get("connector_name", "") + + # ========== VALIDAZIONE BASE: DEVE CONTENERE IL TRATTINO (come DMan/Arb) ========== + if "-" not in pair: + message_id = context.user_data.get("macdbb_wizard_message_id") + wizard_chat_id = context.user_data.get("macdbb_wizard_chat_id", chat_id) + + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_connector")] + ] + + # Mostra il contesto con exchange selezionato (come DMan/Arb) + context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n" + + err_text = ( + r"*πŸ“Š MACD BB V1 \- Step 2*" + "\n\n" + + context_text + + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n" + + r"Type the pair again:" + ) + + if message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"Error editing message: {e}") + # Fallback: invia nuovo messaggio + msg = await context.bot.send_message( + chat_id=chat_id, text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["macdbb_wizard_message_id"] = msg.message_id + else: + msg = await context.bot.send_message( + chat_id=chat_id, text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["macdbb_wizard_message_id"] = msg.message_id + return + + # ========== 2. VALIDAZIONE SULL'EXCHANGE (CON SUGGERIMENTI) come DMan/Arb ========== + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = ( + await validate_trading_pair(context.user_data, client, connector, pair) + ) + + if not is_valid: + message_id = context.user_data.get("macdbb_wizard_message_id") + wizard_chat_id = context.user_data.get("macdbb_wizard_chat_id", chat_id) + + keyboard = [] + for sugg in suggestions[:4]: + keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:macdbb_pair_select:{sugg}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_connector")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + # Mostra il contesto con exchange selezionato (come DMan/Arb) + context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n" + + err_text = ( + r"*πŸ“Š MACD BB V1 \- Step 2*" + "\n\n" + + context_text + + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n" + + r"*Did you mean?*" + ) + + if message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"Error editing message: {e}") + msg = await context.bot.send_message( + chat_id=chat_id, text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["macdbb_wizard_message_id"] = msg.message_id + else: + msg = await context.bot.send_message( + chat_id=chat_id, text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["macdbb_wizard_message_id"] = msg.message_id + return + + if correct_pair: + pair = correct_pair + + config["trading_pair"] = pair + config["candles_trading_pair"] = pair + for key in ["macdbb_current_price", "macdbb_candles", "macdbb_candles_interval"]: + context.user_data.pop(key, None) + set_controller_config(context, config) + # Vai al passo successivo (leverage o amount) + connector = config.get("connector_name", "") + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + + if is_perp: + context.user_data["macdbb_wizard_step"] = "leverage" + # ========== RICOSTRUISCI IL MESSAGGIO QUI (come DMan) ========== + message_id = context.user_data.get("macdbb_wizard_message_id") + wizard_chat_id = context.user_data.get("macdbb_wizard_chat_id", chat_id) + + keyboard = [ + [ + InlineKeyboardButton("1x", callback_data="bots:macdbb_leverage:1"), + InlineKeyboardButton("5x", callback_data="bots:macdbb_leverage:5"), + InlineKeyboardButton("10x", callback_data="bots:macdbb_leverage:10"), + ], + [ + InlineKeyboardButton("20x", callback_data="bots:macdbb_leverage:20"), + InlineKeyboardButton("50x", callback_data="bots:macdbb_leverage:50"), + InlineKeyboardButton("75x", callback_data="bots:macdbb_leverage:75"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + text = ( + r"*πŸ“Š MACD BB V1 \- Step 3/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n\n" + r"⚑ *Select Leverage*" + "\n" + r"_Or type a value \(e\.g\. 20\)_" + ) + + try: + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["macdbb_wizard_message_id"] = message_id + except Exception as e: + logger.error(f"Error showing leverage step: {e}") + # Fallback: invia nuovo messaggio + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["macdbb_wizard_message_id"] = new_msg.message_id + context.user_data["macdbb_wizard_chat_id"] = chat_id + return # <--- IMPORTANTE: esci qui + + else: + # Spot: vai direttamente all'amount + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + set_controller_config(context, config) + context.user_data["macdbb_wizard_step"] = "total_amount_quote" + context.user_data["bots_state"] = "macdbb_wizard_input" + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:macdbb_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:macdbb_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:macdbb_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:macdbb_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:macdbb_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:macdbb_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + text = ( + r"*πŸ“Š MACD BB V1 \- Step 3/4*" + "\n\n" # <--- CAMBIA DA 3/5 A 3/4 + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n\n" + r"πŸ’° *Total Amount \(USDT\)*" + "\n" + r"_Select or type an amount:_" + ) + + try: + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["macdbb_wizard_message_id"] = message_id + except Exception: + pass + return # <--- AGGIUNGI QUESTO return + + # ========== GESTISCI INPUT PER LEVERAGE ========== + elif step == "leverage": + try: + val = int(float(user_input.strip().lower().replace("x", ""))) + config["leverage"] = val + set_controller_config(context, config) + context.user_data["macdbb_wizard_step"] = "total_amount_quote" + await _macdbb_show_amount_step(update, context) + except ValueError: + keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:macdbb_back_to_leverage")]] + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=r"*πŸ“Š MACD BB V1*" + "\n\n" + r"❌ *Invalid leverage*" + "\n\n" + r"Enter a positive number \(e\.g\. 20\):", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + # ========== GESTISCI INPUT PER TOTAL_AMOUNT_QUOTE ========== + elif step == "total_amount_quote": + try: + amount = float(user_input.strip().replace("$", "").replace(",", "")) + config["total_amount_quote"] = amount + set_controller_config(context, config) + context.user_data["macdbb_wizard_step"] = "final" + + # Mostra loading + pair = config.get("trading_pair", "") + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=r"*πŸ“Š MACD BB V1*" + "\n\n" + f"⏳ Loading chart for `{escape_markdown_v2(pair)}`\\.\\.\\. ", + parse_mode="MarkdownV2", + ) + else: + tmp = await context.bot.send_message( + chat_id=wizard_chat_id, + text=r"*πŸ“Š MACD BB V1*" + "\n\n" + f"⏳ Loading chart for `{escape_markdown_v2(pair)}`\\.\\.\\. ", + parse_mode="MarkdownV2", + ) + context.user_data["macdbb_wizard_message_id"] = tmp.message_id + await _macdbb_show_final_step(update, context) + + except ValueError: + keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:macdbb_back_to_amount")]] + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=r"*πŸ“Š MACD BB V1*" + "\n\n" + r"❌ *Invalid amount*" + "\n\n" + r"Enter a positive number \(e\.g\. 500\):", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + # ========== GESTISCI INPUT PER FINAL (EDIT CAMPI) ========== + elif step == "final": + if "=" in user_input: + supported_fields = [ + "id", "connector_name", "trading_pair", "leverage", "position_mode", + "total_amount_quote", "max_executors_per_side", "cooldown_time", + "stop_loss", "take_profit", "take_profit_order_type", "time_limit", + "trailing_stop_activation", "trailing_stop_delta", + "candles_connector", "candles_trading_pair", "interval", + "bb_length", "bb_std", "bb_long_threshold", "bb_short_threshold", + "macd_fast", "macd_slow", "macd_signal", + ] + + for line in user_input.strip().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() + + if field not in supported_fields: + continue + + try: + if field in ("total_amount_quote", "stop_loss", "take_profit", + "bb_std", "bb_long_threshold", "bb_short_threshold", + "trailing_stop_activation", "trailing_stop_delta"): + config[field] = float(value) + elif field in ("leverage", "max_executors_per_side", "cooldown_time", + "take_profit_order_type", "bb_length", + "macd_fast", "macd_slow", "macd_signal"): + config[field] = int(float(value)) + elif field in ("position_mode", "interval"): + config[field] = value.upper() + else: + config[field] = value + except Exception: + pass + + set_controller_config(context, config) + await _macdbb_show_final_step(update, context) + + except Exception as e: + logger.error(f"MACD BB wizard input error: {e}", exc_info=True) + +# ============================================ +# SUPERTREND V1 WIZARD +# ============================================ +# Steps: connector β†’ pair β†’ (leverage) β†’ amount β†’ interval+chart β†’ save +# Prefisso handler: st_ + +async def show_new_supertrend_v1_form(update, context) -> None: + """Start the SuperTrend V1 wizard - Step 1: Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + for key in ["st_current_price", "st_candles", "st_candles_interval", "st_chart_interval"]: + context.user_data.pop(key, None) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + 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}") + + init_new_controller_config(context, "supertrend_v1") + context.user_data["bots_state"] = "st_wizard" + context.user_data["st_wizard_step"] = "connector_name" + context.user_data["st_wizard_message_id"] = query.message.message_id + context.user_data["st_wizard_chat_id"] = query.message.chat_id + + await _st_show_connector_step(update, context) + + +async def _st_show_connector_step(update, context) -> None: + """ST Step 1: Select Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + try: + client, server_name = await get_bots_client(chat_id, context.user_data) + cex_connectors = await get_available_cex_connectors( + context.user_data, client, server_name=server_name + ) + + if not cex_connectors: + keyboard = [ + [InlineKeyboardButton("πŸ”‘ Configure API Keys", callback_data="config_api_keys")], + [InlineKeyboardButton("Β« Back", callback_data="bots:main_menu")], + ] + await query.message.edit_text( + r"*πŸ“ˆ SuperTrend V1 \- New Config*" + "\n\n" + r"⚠️ No CEX connectors available\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + keyboard = [] + row = [] + for connector in cex_connectors: + row.append(InlineKeyboardButton( + f"🏦 {connector}", callback_data=f"bots:st_connector:{connector}" + )) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + await query.message.edit_text( + r"*πŸ“ˆ SuperTrend V1*" + "\n\n" + r"Trend\-following strategy using the SuperTrend indicator\. " + r"Enters LONG when price is above the ST line and within threshold\. " + r"Enters SHORT when price is below the ST line and within threshold\." + "\n\n" + r"─────────────────────────" + "\n\n" + r"*Step 1: Select Exchange*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"ST connector step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + await query.message.edit_text( + format_error_message(f"Error: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_st_wizard_connector(update, context, connector: str) -> None: + config = get_controller_config(context) + config["connector_name"] = connector + config["candles_connector"] = connector + set_controller_config(context, config) + context.user_data["st_wizard_step"] = "trading_pair" + await _st_show_pair_step(update, context) + +async def _st_show_pair_step(update, context) -> None: + """ST Step 2: Select Trading Pair""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + + context.user_data["bots_state"] = "st_wizard_input" + context.user_data["st_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:st_pair:{pair}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 6 if is_perp else 4 + current_step = 2 + + message_text = ( + rf"*πŸ“ˆ SuperTrend V1 \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n" + r"πŸ”— *Trading Pair*" + "\n\n" + r"Select a recent pair or type a new one:" + ) + + # ========== SALVA MESSAGE_ID ========== + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["st_wizard_message_id"] = query.message.message_id + except Exception: + try: + await query.message.delete() + except Exception: + pass + new_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["st_wizard_message_id"] = new_msg.message_id + context.user_data["st_wizard_chat_id"] = query.message.chat_id + +async def handle_st_wizard_pair(update, context, pair: str) -> None: + """Handle pair selection""" + config = get_controller_config(context) + config["trading_pair"] = pair + config["candles_trading_pair"] = pair + set_controller_config(context, config) + connector = config.get("connector_name", "") + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + + if is_perp: + context.user_data["st_wizard_step"] = "leverage" + await _st_show_leverage_step(update, context) + else: + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + set_controller_config(context, config) + context.user_data["st_wizard_step"] = "total_amount_quote" + await _st_show_amount_step(update, context) + +async def _st_show_leverage_step(update, context) -> None: + """ST Step 3 (perp only): Select Leverage""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + + context.user_data["bots_state"] = "st_wizard_input" + context.user_data["st_wizard_step"] = "leverage" + context.user_data["st_wizard_message_id"] = query.message.message_id + context.user_data["st_wizard_chat_id"] = query.message.chat_id + + keyboard = [ + [ + InlineKeyboardButton("1x", callback_data="bots:st_leverage:1"), + InlineKeyboardButton("5x", callback_data="bots:st_leverage:5"), + InlineKeyboardButton("10x", callback_data="bots:st_leverage:10"), + ], + [ + InlineKeyboardButton("20x", callback_data="bots:st_leverage:20"), + InlineKeyboardButton("50x", callback_data="bots:st_leverage:50"), + InlineKeyboardButton("75x", callback_data="bots:st_leverage:75"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + await query.message.edit_text( + r"*πŸ“ˆ SuperTrend V1 \- Step 3/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n\n" + r"⚑ *Select Leverage*" + "\n" + r"_Or type a value \(e\.g\. 20\)_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_st_wizard_leverage(update, context, leverage: int) -> None: + """Handle leverage selection""" + config = get_controller_config(context) + config["leverage"] = leverage + set_controller_config(context, config) + # Vai a position_mode (come DMan V3) + context.user_data["st_wizard_step"] = "position_mode" + await _st_show_position_mode_step(update, context) + +async def _st_show_position_mode_step( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """ST Wizard Step: Select Position Mode (Perpetual/Margin only)""" + query = update.callback_query + config = get_controller_config(context) + + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + leverage = config.get("leverage", 1) + context.user_data["bots_state"] = "st_wizard_input" + context.user_data["st_wizard_step"] = "position_mode" + escaped_connector = escape_markdown_v2(connector) + escaped_pair = escape_markdown_v2(pair) + + keyboard = [ + [ + InlineKeyboardButton("πŸ”’ ONEWAY", callback_data="bots:st_position_mode:ONEWAY"), + InlineKeyboardButton("πŸ”„ HEDGE", callback_data="bots:st_position_mode:HEDGE"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_leverage"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + total_steps = 6 + current_step = 4 + + await query.message.edit_text( + f"*πŸ“ˆ SuperTrend V1 \\- Step {current_step}/{total_steps}*\n\n" + f"🏦 `{escaped_connector}` \\| πŸ”— `{escaped_pair}` \\| ⚑ `{leverage}x`\n\n" + r"🎯 *Position Mode*\n\n" + r"β€’ *ONEWAY*: Can only hold positions in one direction\n" + r"β€’ *HEDGE*: Can hold both long and short simultaneously", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_st_back_to_position_mode(update, context) -> None: + """Go back to position mode step""" + context.user_data["st_wizard_step"] = "position_mode" + await _st_show_position_mode_step(update, context) + +async def _st_show_amount_step(update, context) -> None: + """ST Step: Enter Total Amount""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + leverage = config.get("leverage", 1) + pos_mode = config.get("position_mode", "HEDGE") + + context.user_data["bots_state"] = "st_wizard_input" + context.user_data["st_wizard_step"] = "total_amount_quote" + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 6 if is_perp else 4 + current_step = 5 if is_perp else 3 + + # Back callback + if is_perp: + back_callback = "bots:st_back_to_position_mode" + else: + back_callback = "bots:st_back_to_pair" + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:st_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:st_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:st_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:st_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:st_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:st_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data=back_callback), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + if is_perp: + header_info += f" \\| ⚑ `{leverage}x` \\| 🎯 `{escape_markdown_v2(pos_mode)}`" + + message_text = ( + rf"*πŸ“ˆ SuperTrend V1 \- Step {current_step}/{total_steps}*" + "\n\n" + + header_info + "\n\n" + r"πŸ’° *Total Amount \(USDT\)*" + "\n" + r"_Select or type an amount:_" + ) + + # ========== SALVA MESSAGE_ID ========== + target_chat_id = chat_id + if query and query.message: + target_chat_id = query.message.chat_id + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["st_wizard_message_id"] = query.message.message_id + return + except Exception: + try: + await query.message.delete() + except Exception: + pass + + new_msg = await context.bot.send_message( + chat_id=target_chat_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["st_wizard_message_id"] = new_msg.message_id + context.user_data["st_wizard_chat_id"] = target_chat_id + +async def handle_st_wizard_amount(update, context, amount: float) -> None: + query = update.callback_query + config = get_controller_config(context) + config["total_amount_quote"] = amount + set_controller_config(context, config) + + pair = config.get("trading_pair", "") + await query.message.edit_text( + r"*πŸ“ˆ SuperTrend V1 \- New Config*" + "\n\n" + f"⏳ *Loading chart for* `{escape_markdown_v2(pair)}`\\.\\.\\. " + "\n\n" + r"_Fetching market data\.\.\._", + parse_mode="MarkdownV2", + ) + + context.user_data["st_wizard_step"] = "final" + await _st_show_final_step(update, context) + +async def _st_show_final_step(update, context, interval: str = None) -> None: + """ST Final Step: Chart + Config Summary + Analysis""" + import asyncio + + query = update.callback_query + msg = query.message if query else update.message + chat_id = update.effective_chat.id + config = get_controller_config(context) + + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + total_amount = config.get("total_amount_quote", 1000) + leverage = config.get("leverage", 1) + length = config.get("length", 20) + multiplier = config.get("multiplier", 4.0) + pct_threshold = config.get("percentage_threshold", 0.01) + + if interval is None: + interval = context.user_data.get("st_chart_interval", config.get("interval", "15m")) + context.user_data["st_chart_interval"] = interval + config["interval"] = interval + set_controller_config(context, config) + + current_price = context.user_data.get("st_current_price") + candles = context.user_data.get("st_candles") + + try: + cached_interval = context.user_data.get("st_candles_interval", interval) + if not current_price or interval != cached_interval: + # Usa MarkdownV2 invece di HTML + try: + await msg.edit_text( + r"*πŸ“ˆ SuperTrend V1 \- New Config*" + "\n\n" + f"⏳ Fetching market data for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + except Exception: + pass + + client, _ = await get_bots_client(chat_id, context.user_data) + + # ========== FIX: USA SEMPRE IL CONNETTORE SPOT PER LE CANDELE ========== + candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "") + if not candles_connector or len(candles_connector) < 3: + candles_connector = "kucoin" + + logger.info(f"ST: Trading on {connector}, using candles from {candles_connector}") + + # Fetch current price + try: + current_price = await asyncio.wait_for( + fetch_current_price(client, connector, pair), + timeout=10.0 + ) + if current_price: + context.user_data["st_current_price"] = current_price + except (asyncio.TimeoutError, Exception) as e: + logger.warning(f"Could not fetch price for {pair}: {e}") + current_price = None + + # Fetch candles + if current_price: + pair_variants = [pair, pair.replace("-", "/")] + candles = None + + for try_pair in pair_variants: + try: + logger.info(f"ST: Trying candles from {candles_connector} for {try_pair}") + candles = await asyncio.wait_for( + fetch_candles(client, candles_connector, try_pair, interval=interval, max_records=420), + timeout=15.0 + ) + if candles: + candles_data = candles.get("data", []) if isinstance(candles, dict) else (candles or []) + if candles_data and len(candles_data) > 0: + logger.info(f"ST: Got {len(candles_data)} candles from {candles_connector} for {try_pair}") + break + else: + candles = None + else: + candles = None + except asyncio.TimeoutError: + logger.warning(f"ST: Timeout fetching candles from {candles_connector} for {try_pair}") + except Exception as e: + logger.warning(f"ST: Error fetching candles from {candles_connector} for {try_pair}: {e}") + + if candles: + context.user_data["st_candles"] = candles + context.user_data["st_candles_interval"] = interval + else: + logger.warning(f"ST: Could not fetch candles for {pair}") + + if not current_price: + keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]] + await msg.edit_text( + r"*❌ Error*" + "\n\n" + f"Could not fetch price for `{escape_markdown_v2(pair)}`\\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + candles_list = [] + if candles: + candles_list = candles.get("data", []) if isinstance(candles, dict) else (candles or []) + if candles_list: + last_close = candles_list[-1].get("close") or candles_list[-1].get("c") + if last_close: + current_price = float(last_close) + context.user_data["st_current_price"] = current_price + + # Generate config ID if not set + if not config.get("id"): + existing_configs = context.user_data.get("controller_configs_list", []) + from .controllers.supertrend_v1.config import generate_id as st_generate_id + config["id"] = st_generate_id(config, existing_configs) + + # Run analysis + from .controllers.supertrend_v1.analysis import analyze_candles_for_supertrend, format_supertrend_analysis + analysis = analyze_candles_for_supertrend( + candles_list, + length=length, + multiplier=multiplier, + percentage_threshold=pct_threshold, + ) + + # Auto-save suggested threshold + config["percentage_threshold"] = analysis["suggested_percentage_threshold"] + pct_threshold = config["percentage_threshold"] + set_controller_config(context, config) + + position_mode = config.get("position_mode", "HEDGE") + stop_loss = config.get("stop_loss", 0.05) + take_profit = config.get("take_profit", 0.03) + max_exec = config.get("max_executors_per_side", 1) + cooldown = config.get("cooldown_time", 60) + ts = config.get("trailing_stop", {}) or {} + ts_act = ts.get("activation_price", 0.015) if isinstance(ts, dict) else 0.015 + ts_delta = ts.get("trailing_delta", 0.005) if isinstance(ts, dict) else 0.005 + + context.user_data["bots_state"] = "st_wizard_input" + context.user_data["st_wizard_step"] = "final" + + interval_options = ["1m", "5m", "15m", "1h", "8h"] + interval_row = [ + InlineKeyboardButton( + f"βœ“ {opt}" if opt == interval else opt, + callback_data=f"bots:st_interval:{opt}" + ) + for opt in interval_options + ] + + context.user_data["st_analysis"] = analysis + + strategy_row = [ + InlineKeyboardButton("🎯 Scalp", callback_data="bots:st_set_strat:scalping"), + InlineKeyboardButton("βš–οΈ Swing", callback_data="bots:st_set_strat:swing"), + InlineKeyboardButton("πŸ€– Auto", callback_data="bots:st_set_strat:auto"), + ] + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + final_step = 6 if is_perp else 4 + + keyboard = [ + interval_row, + strategy_row, + [InlineKeyboardButton("πŸ’Ύ Save Config", callback_data="bots:st_save")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_amount"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + # ========== COSTRUISCI CONFIG_TEXT IN MARKDOWNV2 (COME MACD BB) ========== + escaped_pair = escape_markdown_v2(pair) + escaped_connector = escape_markdown_v2(connector) + escaped_position_mode = escape_markdown_v2(position_mode) + escaped_interval = escape_markdown_v2(interval) + escaped_title = escape_markdown_v2(f"πŸ“ˆ SuperTrend V1 - Step {final_step}/{final_step} (Final)") + + # IMPORTANTE: escapa anche il pipe e il punto + price_line = f"Price: `{current_price:,.6g}` | ST length: `{length}` | Multiplier: `{multiplier}` | Interval: `{escaped_interval}`" + escaped_price_line = escape_markdown_v2(price_line) + + config_text = ( + f"*{escaped_title}*\n\n" + f"*{escaped_pair}*\n" + f"{escaped_price_line}\n\n" + f"`connector_name={escaped_connector}`\n" + f"`trading_pair={escaped_pair}`\n" + f"`total_amount_quote={total_amount:.0f}`\n" + f"`leverage={leverage}`\n" + f"`position_mode={escaped_position_mode}`\n" + f"`max_executors_per_side={max_exec}`\n" + f"`cooldown_time={cooldown}`\n" + f"`stop_loss={stop_loss}`\n" + f"`take_profit={take_profit}`\n" + f"`trailing_stop_activation={ts_act}`\n" + f"`trailing_stop_delta={ts_delta}`\n" + f"`interval={escaped_interval}`\n" + f"`length={length}`\n" + f"`multiplier={multiplier}`\n" + f"`percentage_threshold={pct_threshold}`\n\n" + r"_Edit: `field=value`_" + ) + + # Analysis con tripli backtick (non serve escape qui) + analysis_text = format_supertrend_analysis(analysis) + config_text += "\n\n```\n" + analysis_text + "\n```" + + # ========== INVIA CON MARKDOWNV2 (NON HTML) ========== + if not candles_list: + try: + await msg.edit_text( + text=config_text, + parse_mode="MarkdownV2", # <-- CAMBIATO + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["st_wizard_message_id"] = msg.message_id + except Exception: + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=config_text, + parse_mode="MarkdownV2", # <-- CAMBIATO + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["st_wizard_message_id"] = new_msg.message_id + return + + # ========== TENTA DI GENERARE IL GRAFICO ========== + try: + from .controllers.supertrend_v1.chart import generate_chart as st_chart + chart_bytes = st_chart(config, candles_list, current_price) + chart_bytes.seek(0) + + stored_msg_id = context.user_data.get("st_wizard_message_id") + stored_chat_id = context.user_data.get("st_wizard_chat_id", chat_id) + if stored_msg_id: + try: + await context.bot.delete_message(chat_id=stored_chat_id, message_id=stored_msg_id) + except Exception: + pass + try: + await msg.delete() + except Exception: + pass + + new_msg = await context.bot.send_photo( + chat_id=chat_id, + photo=chart_bytes, + caption=config_text, + parse_mode="MarkdownV2", # <-- CAMBIATO + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["st_wizard_message_id"] = new_msg.message_id + context.user_data["st_wizard_chat_id"] = chat_id + except Exception as chart_err: + logger.warning(f"Chart generation failed: {chart_err}, sending text-only") + try: + await msg.edit_text( + text=config_text, + parse_mode="MarkdownV2", # <-- CAMBIATO + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["st_wizard_message_id"] = msg.message_id + except Exception: + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=config_text, + parse_mode="MarkdownV2", # <-- CAMBIATO + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["st_wizard_message_id"] = new_msg.message_id + + except Exception as e: + logger.error(f"ST final step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + try: + await msg.edit_text( + format_error_message(f"Error: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + pass + +async def handle_st_interval_change(update, context, interval: str) -> None: + context.user_data["st_candles"] = None + context.user_data["st_candles_interval"] = None + await _st_show_final_step(update, context, interval=interval) + +async def handle_st_set_strategy(update, context, strat_key: str) -> None: + """Handle SuperTrend strategy selection from final step buttons.""" + query = update.callback_query + config = get_controller_config(context) + + analysis = context.user_data.get("st_analysis", {}) + natr = analysis.get("natr", 0.01) + + from .controllers.supertrend_v1.analysis import get_st_strategy_suggestions + strats = get_st_strategy_suggestions(natr, analysis) + + if strat_key in strats: + selected = strats[strat_key] + + # Aggiorna i parametri + config["length"] = selected.get("length", 20) + config["multiplier"] = selected.get("multiplier", 4.0) + config["percentage_threshold"] = selected.get("percentage_threshold", 0.01) + config["take_profit"] = selected.get("take_profit", 0.03) + config["stop_loss"] = selected.get("stop_loss", 0.05) + + # Trailing stop + if "trailing_stop_activation" in selected: + if not isinstance(config.get("trailing_stop"), dict): + config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005} + config["trailing_stop"]["activation_price"] = selected["trailing_stop_activation"] + config["trailing_stop"]["trailing_delta"] = selected["trailing_stop_delta"] + + set_controller_config(context, config) + + vol_regime = selected.get("volatility_regime", "moderate") + await query.answer(f"βœ… {selected['label']} applicata (vol: {vol_regime})") + + return await _st_show_final_step(update, context) + +async def handle_st_save(update, context) -> None: + """Save SuperTrend V1 configuration""" + query = update.callback_query + config = get_controller_config(context) + config.pop("candles_config", None) + config_id = config.get("id", "") + chat_id = query.message.chat_id + + try: + await query.message.delete() + except Exception: + pass + + status_msg = await context.bot.send_message( + chat_id=chat_id, + text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + await client.controllers.create_or_update_controller_config(config_id, config) + + for key in ["st_wizard_step", "st_wizard_message_id", "st_wizard_chat_id", + "st_current_price", "st_candles", "st_candles_interval", "st_chart_interval"]: + context.user_data.pop(key, None) + context.user_data.pop("bots_state", None) + + keyboard = [ + [InlineKeyboardButton("Create Another", callback_data="bots:new_supertrend_v1")], + [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + "*Config Saved\\!*\n\n" + "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"ST save error: {e}", exc_info=True) + keyboard = [ + [InlineKeyboardButton("Try Again", callback_data="bots:st_save")], + [InlineKeyboardButton("Back", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + format_error_message(f"Failed to save: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_st_back_to_connector(update, context) -> None: + context.user_data["st_wizard_step"] = "connector_name" + await _st_show_connector_step(update, context) + +async def handle_st_back_to_pair(update, context) -> None: + context.user_data["st_wizard_step"] = "trading_pair" + await _st_show_pair_step(update, context) + +async def handle_st_back_to_leverage(update, context) -> None: + config = get_controller_config(context) + connector = config.get("connector_name", "") + # CORREZIONE: supporta _margin + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + if is_perp: + context.user_data["st_wizard_step"] = "leverage" + await _st_show_leverage_step(update, context) + else: + await handle_st_back_to_pair(update, context) + + +async def handle_st_back_to_amount(update, context) -> None: + context.user_data["st_wizard_step"] = "total_amount_quote" + context.user_data.pop("st_current_price", None) + context.user_data.pop("st_candles", None) + await _st_show_amount_step(update, context) + +async def handle_st_position_mode(update, context, mode: str) -> None: + """Handle position mode selection""" + config = get_controller_config(context) + config["position_mode"] = mode + set_controller_config(context, config) + context.user_data["st_wizard_step"] = "total_amount_quote" + await _st_show_amount_step(update, context) + +async def handle_st_pair_select( + update: Update, context: ContextTypes.DEFAULT_TYPE, trading_pair: str +) -> None: + """Handle selection of a suggested trading pair in SuperTrend wizard""" + config = get_controller_config(context) + chat_id = update.effective_chat.id + + # Clear old market data + for key in ["st_current_price", "st_candles", "st_candles_interval"]: + context.user_data.pop(key, None) + + config["trading_pair"] = trading_pair + config["candles_trading_pair"] = trading_pair + set_controller_config(context, config) + + connector = config.get("connector_name", "") + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + + if is_perp: + context.user_data["st_wizard_step"] = "leverage" + await _st_show_leverage_step(update, context) + else: + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + set_controller_config(context, config) + context.user_data["st_wizard_step"] = "total_amount_quote" + await _st_show_amount_step(update, context) + +async def process_st_wizard_input(update, context, user_input: str) -> None: + """Process text input during Supertrend V1 wizard""" + step = context.user_data.get("st_wizard_step") + chat_id = update.effective_chat.id + config = get_controller_config(context) + message_id = context.user_data.get("st_wizard_message_id") + wizard_chat_id = context.user_data.get("st_wizard_chat_id", chat_id) + try: + try: + await update.message.delete() + except Exception: + pass + + if step == "trading_pair": + pair = user_input.upper().strip().replace("/", "-").replace("_", "-") + connector = config.get("connector_name", "") + + # ========== VALIDAZIONE BASE: DEVE CONTENERE IL TRATTINO ========== + if "-" not in pair: + message_id = context.user_data.get("st_wizard_message_id") + wizard_chat_id = context.user_data.get("st_wizard_chat_id", chat_id) + + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_connector")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + ] + + context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n" + + err_text = ( + r"*πŸ“ˆ Supertrend V1 \- Step 2*" + "\n\n" + + context_text + + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n" + r"Type the pair again:" + ) + + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + else: + msg = await context.bot.send_message( + chat_id=chat_id, text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["st_wizard_message_id"] = msg.message_id + return + + # ========== 2. VALIDAZIONE SULL'EXCHANGE (CON SUGGERIMENTI) ========== + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = ( + await validate_trading_pair(context.user_data, client, connector, pair) + ) + + if not is_valid: + message_id = context.user_data.get("st_wizard_message_id") + wizard_chat_id = context.user_data.get("st_wizard_chat_id", chat_id) + + keyboard = [] + for sugg in suggestions[:4]: + keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:st_pair_select:{sugg}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_connector")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n" + + err_text = ( + r"*πŸ“ˆ Supertrend V1 \- Step 2*" + "\n\n" + + context_text + + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n" + r"*Did you mean?*" + ) + + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + else: + msg = await context.bot.send_message( + chat_id=chat_id, text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["st_wizard_message_id"] = msg.message_id + return + + if correct_pair: + pair = correct_pair + + config["trading_pair"] = pair + config["candles_trading_pair"] = pair + for key in ["st_current_price", "st_candles", "st_candles_interval"]: + context.user_data.pop(key, None) + set_controller_config(context, config) + + connector = config.get("connector_name", "") + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + message_id = context.user_data.get("st_wizard_message_id") + wizard_chat_id = context.user_data.get("st_wizard_chat_id", chat_id) + + if is_perp: + context.user_data["st_wizard_step"] = "leverage" + keyboard = [ + [ + InlineKeyboardButton("1x", callback_data="bots:st_leverage:1"), + InlineKeyboardButton("5x", callback_data="bots:st_leverage:5"), + InlineKeyboardButton("10x", callback_data="bots:st_leverage:10"), + ], + [ + InlineKeyboardButton("20x", callback_data="bots:st_leverage:20"), + InlineKeyboardButton("50x", callback_data="bots:st_leverage:50"), + InlineKeyboardButton("75x", callback_data="bots:st_leverage:75"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + text = ( + r"*πŸ“ˆ Supertrend V1 \- Step 3/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n\n" + r"⚑ *Select Leverage*" + "\n" + r"_Or type a value \(e\.g\. 20\)_" + ) + else: + config["leverage"] = 1 + set_controller_config(context, config) + context.user_data["st_wizard_step"] = "total_amount_quote" + context.user_data["bots_state"] = "st_wizard_input" + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:st_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:st_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:st_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:st_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:st_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:st_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + text = ( + r"*πŸ“ˆ Supertrend V1 \- Step 3/4*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n\n" + r"πŸ’° *Total Amount \(USDT\)*" + "\n" + r"_Select or type an amount:_" + ) + + try: + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["st_wizard_message_id"] = message_id + except Exception: + pass + return + + elif step == "leverage": + val = int(float(user_input.strip().lower().replace("x", ""))) + config["leverage"] = val + set_controller_config(context, config) + context.user_data["st_wizard_step"] = "total_amount_quote" + message_id = context.user_data.get("st_wizard_message_id") + wizard_chat_id = context.user_data.get("st_wizard_chat_id", chat_id) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:st_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:st_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:st_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:st_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:st_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:st_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_leverage"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + text = ( + r"*πŸ“ˆ Supertrend V1 \- Step 4/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}` \\| ⚑ `{val}x`" + "\n\n" + r"πŸ’° *Total Amount \(USDT\)*" + "\n" + r"_Select or type an amount:_" + ) + context.user_data["bots_state"] = "st_wizard_input" + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + elif step == "total_amount_quote": + amount = float(user_input.strip().replace("$", "").replace(",", "")) + config["total_amount_quote"] = amount + set_controller_config(context, config) + context.user_data["st_wizard_step"] = "final" + wizard_chat_id = context.user_data.get("st_wizard_chat_id", chat_id) + pair = config.get("trading_pair", "") + tmp = await context.bot.send_message( + chat_id=wizard_chat_id, + text=r"*πŸ“ˆ Supertrend V1*" + "\n\n" + f"⏳ Loading chart for `{escape_markdown_v2(pair)}`\\.\\.\\. ", + parse_mode="MarkdownV2", + ) + context.user_data["st_wizard_message_id"] = tmp.message_id + await _st_show_final_step(update, context) + + elif step == "final": + # Handle field=value edits + if "=" in user_input: + for line in user_input.strip().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() + try: + if field in ("total_amount_quote", "stop_loss", "take_profit", + "trailing_stop_activation", "trailing_stop_delta", + "supertrend_multiplier", "supertrend_period"): + val = float(value) + if field == "trailing_stop_activation": + if not isinstance(config.get("trailing_stop"), dict): + config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005} + config["trailing_stop"]["activation_price"] = val + elif field == "trailing_stop_delta": + if not isinstance(config.get("trailing_stop"), dict): + config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005} + config["trailing_stop"]["trailing_delta"] = val + else: + config[field] = val + elif field in ("leverage", "max_executors_per_side", "cooldown_time", + "take_profit_order_type"): + config[field] = int(float(value)) + elif field == "interval": + config["interval"] = value + context.user_data.pop("st_candles", None) + context.user_data["st_chart_interval"] = value + else: + config[field] = value + except Exception: + pass + set_controller_config(context, config) + + except Exception as e: + logger.error(f"ST wizard input error: {e}", exc_info=True) +# ============================================================================= +# ANTIFOLLA +# ============================================================================= + +async def show_new_anti_folla_v1_form(update, context) -> None: + """Start the Anti-Folla V1 wizard - Step 1: Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + for key in list(context.user_data.keys()): + if key.endswith("_wizard_message_id") or key.endswith("_wizard_chat_id"): + context.user_data.pop(key, None) + if key.endswith("_wizard_step"): + context.user_data.pop(key, None) + try: + client, _ = await get_bots_client(chat_id, context.user_data) + 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}") + + init_new_controller_config(context, "anti_folla_v1") + context.user_data["bots_state"] = "af_wizard" + context.user_data["af_wizard_step"] = "connector_name" + context.user_data["af_wizard_message_id"] = query.message.message_id + context.user_data["af_wizard_chat_id"] = query.message.chat_id + + await _af_show_connector_step(update, context) + + +async def _af_show_connector_step(update, context) -> None: + query = update.callback_query + chat_id = update.effective_chat.id + try: + client, server_name = await get_bots_client(chat_id, context.user_data) + cex_connectors = await get_available_cex_connectors( + context.user_data, client, server_name=server_name + ) + if not cex_connectors: + keyboard = [ + [InlineKeyboardButton("πŸ”‘ Configure API Keys", callback_data="config_api_keys")], + [InlineKeyboardButton("Β« Back", callback_data="bots:main_menu")], + ] + await query.message.edit_text( + r"*πŸ¦… Anti\-Folla V1 \- New Config*" + "\n\n" r"⚠️ No CEX connectors available\.", + parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + keyboard = [] + row = [] + for connector in cex_connectors: + row.append(InlineKeyboardButton(f"🏦 {connector}", callback_data=f"bots:af_connector:{connector}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + await query.message.edit_text( + r"*πŸ¦… Anti\-Folla V1*" + "\n\n" + r"Crowd\-contrarian: VWAP, Donchian, OBV divergence, OBI, Volume Spike, Whale, Funding Rate\." + "\n\n" + r"─────────────────────────" + "\n\n" r"*Step 1: Select Exchange*", + parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"AF connector step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + await query.message.edit_text(format_error_message(f"Error: {str(e)}"), + parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard)) + + +async def handle_af_wizard_connector(update, context, connector: str) -> None: + config = get_controller_config(context) + config["connector_name"] = connector + config["candles_connector"] = connector + # Auto-set is_perpetual + config["is_perpetual"] = connector.endswith("_perpetual") + set_controller_config(context, config) + context.user_data["af_wizard_step"] = "trading_pair" + await _af_show_pair_step(update, context) + +async def _af_show_pair_step(update, context) -> None: + """AF Step 2: Select Trading Pair""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + + context.user_data["bots_state"] = "af_wizard_input" + context.user_data["af_wizard_step"] = "trading_pair" + + # Recent pairs from existing configs + 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:af_pair:{pair}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 6 if is_perp else 4 + current_step = 2 + + # NOTA: Il nome "Anti-Folla" ha un trattino che deve essere escapato con \- + # Oppure usa r"*πŸ¦… Anti\-Folla V1 ..." come in DMan + message_text = ( + rf"*πŸ¦… Anti\-Folla V1 \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n" + r"πŸ”— *Trading Pair*" + "\n\n" + r"Select a recent pair or type a new one:" + ) + + # ========== SALVA MESSAGE_ID ========== + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["af_wizard_message_id"] = query.message.message_id + except Exception: + try: + await query.message.delete() + except Exception: + pass + new_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["af_wizard_message_id"] = new_msg.message_id + context.user_data["af_wizard_chat_id"] = query.message.chat_id + +async def handle_af_wizard_pair(update, context, pair: str) -> None: + config = get_controller_config(context) + config["trading_pair"] = pair + config["candles_trading_pair"] = pair + for key in list(context.user_data.keys()): + if key.endswith("_wizard_message_id") or key.endswith("_wizard_chat_id"): + context.user_data.pop(key, None) + if key.endswith("_wizard_step"): + context.user_data.pop(key, None) + set_controller_config(context, config) + + connector = config.get("connector_name", "") + # CORREZIONE: supporta _margin + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + + if is_perp: + context.user_data["af_wizard_step"] = "leverage" + await _af_show_leverage_step(update, context) + else: + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + set_controller_config(context, config) + context.user_data["af_wizard_step"] = "total_amount_quote" + await _af_show_amount_step(update, context) + +async def _af_show_leverage_step(update, context) -> None: + """AF Step 3 (perp only): Select Leverage""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + + context.user_data["bots_state"] = "af_wizard_input" + context.user_data["af_wizard_step"] = "leverage" + + keyboard = [ + [ + InlineKeyboardButton("1x", callback_data="bots:af_leverage:1"), + InlineKeyboardButton("5x", callback_data="bots:af_leverage:5"), + InlineKeyboardButton("10x", callback_data="bots:af_leverage:10"), + ], + [ + InlineKeyboardButton("20x", callback_data="bots:af_leverage:20"), + InlineKeyboardButton("50x", callback_data="bots:af_leverage:50"), + InlineKeyboardButton("75x", callback_data="bots:af_leverage:75"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + # ========== SALVA message_id ========== + context.user_data["af_wizard_message_id"] = query.message.message_id + context.user_data["af_wizard_chat_id"] = query.message.chat_id + # ====================================== + + await query.message.edit_text( + r"*πŸ¦… Anti\-Folla V1 \- Step 3/6*" + "\n\n" # <--- Anti\-Folla con backslash + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n\n" + r"⚑ *Select Leverage*" + "\n" + r"_Or type a value \(e\.g\. 20\)_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_af_wizard_leverage(update, context, leverage: int) -> None: + """Handle leverage selection""" + config = get_controller_config(context) + config["leverage"] = leverage + set_controller_config(context, config) + # Vai a position_mode + context.user_data["af_wizard_step"] = "position_mode" + await _af_show_position_mode_step(update, context) + +async def _af_show_position_mode_step(update, context) -> None: + """AF Step 4 (derivati only): HEDGE vs ONEWAY""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + leverage = config.get("leverage", 1) + + context.user_data["bots_state"] = "af_wizard_input" + context.user_data["af_wizard_step"] = "position_mode" + + keyboard = [ + [ + InlineKeyboardButton("πŸ”€ HEDGE βœ… recommended", callback_data="bots:af_position_mode:HEDGE"), + InlineKeyboardButton("➑️ ONEWAY", callback_data="bots:af_position_mode:ONEWAY"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_leverage"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + # CORREZIONE: escape di tutti i caratteri speciali MarkdownV2 + escaped_connector = escape_markdown_v2(connector) + escaped_pair = escape_markdown_v2(pair) + + # NOTA: "Anti-Folla" ha un trattino che deve essere escapato come "Anti\-Folla" + await query.message.edit_text( + f"*πŸ¦… Anti\\-Folla V1 \\- Step 4/6*\n\n" + f"🏦 `{escaped_connector}` \\| πŸ”— `{escaped_pair}` \\| ⚑ `{leverage}x`\n\n" + r"πŸ“ *Position Mode*" + "\n\n" + r"β€’ *HEDGE*: Can hold both long and short positions simultaneously" + "\n" + r"β€’ *ONEWAY*: Can only hold positions in one direction \(long OR short\)", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_af_position_mode(update, context, mode: str) -> None: + """Handle position mode selection for Anti-Folla V1""" + config = get_controller_config(context) + config["position_mode"] = mode + set_controller_config(context, config) + context.user_data["af_wizard_step"] = "total_amount_quote" + await _af_show_amount_step(update, context) + +async def _af_show_amount_step(update, context) -> None: + """AF Step: Enter Total Amount""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + leverage = config.get("leverage", 1) + pos_mode = config.get("position_mode", "HEDGE") + + context.user_data["bots_state"] = "af_wizard_input" + context.user_data["af_wizard_step"] = "total_amount_quote" + connector = config.get("connector_name", "").lower() + is_perp = "_perpetual" in connector or "_margin" in connector + total_steps = 6 if is_perp else 4 + current_step = 5 if is_perp else 3 + + back_callback = "bots:af_back_to_position_mode" if is_perp else "bots:af_back_to_pair" + + # Fetch balance + balance_text = "" + try: + client, _ = await get_bots_client(chat_id, context.user_data) + balances = await get_cex_balances( + context.user_data, client, "master_account", ttl=30 + ) + + # Flexible matching come in Grid Strike + 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", "") + if bal_lower == connector_lower or bal_base == connector_base: + connector_balances = bal_list + break + + if connector_balances: + relevant_balances = [] + quote = pair.split("-")[1] if "-" in pair else "USDT" + for bal in connector_balances: + token = bal.get("token", bal.get("asset", "")) + available = bal.get("units", bal.get("available_balance", bal.get("free", 0))) + if token and token.upper() == quote.upper(): + try: + available_float = float(available) + if available_float > 0: + balance_text = f"\n\nπŸ’° Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`" + break + except (ValueError, TypeError): + continue + except Exception as e: + logger.warning(f"Could not fetch balances for AntiFolla v.1 amount step: {e}") + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:af_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:af_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:af_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:af_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:af_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:af_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data=back_callback), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + if is_perp: + header_info += f" \\| ⚑ `{leverage}x` \\| 🎯 `{escape_markdown_v2(pos_mode)}`" + + message_text = ( + rf"*πŸ¦… Anti\-Folla V1 \- Step {current_step}/{total_steps}*" + "\n\n" + + header_info + balance_text + "\n\n" + r"πŸ’° *Total Amount \(USDT\)*" + "\n" + r"_Select or type an amount:_" + ) + + # ========== SALVA MESSAGE_ID ========== + target_chat_id = chat_id + if query and query.message: + target_chat_id = query.message.chat_id + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["af_wizard_message_id"] = query.message.message_id + return + except Exception: + try: + await query.message.delete() + except Exception: + pass + + new_msg = await context.bot.send_message( + chat_id=target_chat_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["af_wizard_message_id"] = new_msg.message_id + context.user_data["af_wizard_chat_id"] = target_chat_id + +async def handle_af_wizard_amount(update, context, amount: float) -> None: + query = update.callback_query + config = get_controller_config(context) + config["total_amount_quote"] = amount + set_controller_config(context, config) + pair = config.get("trading_pair", "") + await query.message.edit_text( + r"*πŸ¦… Anti\-Folla V1 \- New Config*" + "\n\n" + f"⏳ *Loading chart for* `{escape_markdown_v2(pair)}`\\.\\.\\. " + "\n\n" + r"_Fetching market data\.\.\._", + parse_mode="MarkdownV2", + ) + context.user_data["af_wizard_step"] = "final" + await _af_show_final_step(update, context) + +async def _af_show_final_step(update, context, interval: str = None) -> None: + """Anti-Folla Final Step: Chart + Config Summary + Analysis""" + import asyncio + + query = update.callback_query + msg = query.message if query else update.message + chat_id = update.effective_chat.id + config = get_controller_config(context) + + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + total_amount = config.get("total_amount_quote", 1000) + leverage = config.get("leverage", 1) + vwap_period = config.get("vwap_period", 20) + donchian_period = config.get("donchian_period", 20) + score_buy = config.get("score_buy_threshold", 50.0) + score_sell = config.get("score_sell_threshold", -50.0) + + if interval is None: + interval = context.user_data.get("af_chart_interval", config.get("interval", "5m")) + context.user_data["af_chart_interval"] = interval + config["interval"] = interval + set_controller_config(context, config) + + current_price = context.user_data.get("af_current_price") + candles = context.user_data.get("af_candles") + + try: + cached_interval = context.user_data.get("af_candles_interval", interval) + if not current_price or interval != cached_interval: + # Usa MarkdownV2 invece di HTML + try: + await msg.edit_text( + r"*πŸ¦… Anti\-Folla V1 \- New Config*" + "\n\n" + f"⏳ Fetching market data for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + except Exception: + pass + + client, _ = await get_bots_client(chat_id, context.user_data) + + # ========== PULISCI IL CONNETTORE PER LE CANDELE ========== + candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "") + if not candles_connector or len(candles_connector) < 3: + candles_connector = "kucoin" + logger.info(f"AF: Trading on {connector}, using candles from {candles_connector}") + + # Fetch current price + try: + current_price = await asyncio.wait_for( + fetch_current_price(client, connector, pair), + timeout=10.0 + ) + if current_price: + context.user_data["af_current_price"] = current_price + except (asyncio.TimeoutError, Exception) as e: + logger.warning(f"Could not fetch price for {pair}: {e}") + current_price = None + + # Fetch candles + if current_price: + pair_variants = [pair, pair.replace("-", "/")] + candles = None + + for try_pair in pair_variants: + try: + logger.info(f"AF: Trying candles from {candles_connector} for {try_pair}") + candles = await asyncio.wait_for( + fetch_candles(client, candles_connector, try_pair, interval=interval, max_records=420), + timeout=15.0 + ) + if candles: + candles_data = candles.get("data", []) if isinstance(candles, dict) else (candles or []) + if candles_data and len(candles_data) > 0: + logger.info(f"AF: Got {len(candles_data)} candles from {candles_connector} for {try_pair}") + context.user_data["af_candles"] = {"data": candles_data} + context.user_data["af_candles_interval"] = interval + break + else: + candles = None + else: + candles = None + except asyncio.TimeoutError: + logger.warning(f"AF: Timeout fetching candles from {candles_connector} for {try_pair}") + except Exception as e: + logger.warning(f"AF: Error fetching candles from {candles_connector} for {try_pair}: {e}") + + if not current_price: + keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]] + await msg.edit_text( + r"*❌ Error*" + "\n\n" + f"Could not fetch price for `{escape_markdown_v2(pair)}`\\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + candles_list = [] + if candles: + candles_list = candles.get("data", []) if isinstance(candles, dict) else (candles or []) + if candles_list: + last_close = candles_list[-1].get("close") or candles_list[-1].get("c") + if last_close: + current_price = float(last_close) + context.user_data["af_current_price"] = current_price + + if not config.get("id"): + existing_configs = context.user_data.get("controller_configs_list", []) + from .controllers.anti_folla_v1.config import generate_id as af_generate_id + config["id"] = af_generate_id(config, existing_configs) + + from .controllers.anti_folla_v1.analysis import analyze_candles_for_anti_folla, format_anti_folla_analysis + analysis = analyze_candles_for_anti_folla( + candles_list, vwap_period=vwap_period, donchian_period=donchian_period, + score_buy_threshold=score_buy, score_sell_threshold=score_sell, + ) + set_controller_config(context, config) + + position_mode = config.get("position_mode", "HEDGE") + stop_loss = config.get("stop_loss", 0.05) + take_profit = config.get("take_profit", 0.03) + max_exec = config.get("max_executors_per_side", 1) + cooldown = config.get("cooldown_time", 60) + ts = config.get("trailing_stop", {}) or {} + ts_act = ts.get("activation_price", 0.015) if isinstance(ts, dict) else 0.015 + ts_delta = ts.get("trailing_delta", 0.005) if isinstance(ts, dict) else 0.005 + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + final_step = 6 if is_perp else 4 + + context.user_data["bots_state"] = "af_wizard_input" + context.user_data["af_wizard_step"] = "final" + + interval_options = ["1m", "5m", "15m", "1h", "8h"] + interval_row = [ + InlineKeyboardButton( + f"βœ“ {opt}" if opt == interval else opt, + callback_data=f"bots:af_interval:{opt}" + ) + for opt in interval_options + ] + + context.user_data["af_analysis"] = analysis + + strategy_row = [ + InlineKeyboardButton("πŸ”₯ Aggr", callback_data="bots:af_set_strat:aggressive"), + InlineKeyboardButton("βš–οΈ Balanced", callback_data="bots:af_set_strat:balanced"), + InlineKeyboardButton("πŸ›‘οΈ Cons", callback_data="bots:af_set_strat:conservative"), + ] + + keyboard = [ + interval_row, + strategy_row, + [InlineKeyboardButton("πŸ’Ύ Save Config", callback_data="bots:af_save")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_amount"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + # ========== COSTRUISCI CONFIG_TEXT IN MARKDOWNV2 (COME MACD BB) ========== + escaped_pair = escape_markdown_v2(pair) + escaped_connector = escape_markdown_v2(connector) + escaped_position_mode = escape_markdown_v2(position_mode) + escaped_interval = escape_markdown_v2(interval) + escaped_title = escape_markdown_v2(f"πŸ¦… Anti-Folla V1 - Step {final_step}/{final_step} (Final)") + + # Escapa anche il pipe + price_line = f"Price: `{current_price:,.6g}` | VWAP: `{vwap_period}` | DC: `{donchian_period}` | Interval: `{escaped_interval}`" + escaped_price_line = escape_markdown_v2(price_line) + + config_text = ( + f"*{escaped_title}*\n\n" + f"*{escaped_pair}*\n" + f"{escaped_price_line}\n\n" + f"`connector_name={escaped_connector}`\n" + f"`trading_pair={escaped_pair}`\n" + f"`total_amount_quote={total_amount:.0f}`\n" + f"`leverage={leverage}`\n" + f"`position_mode={escaped_position_mode}`\n" + f"`max_executors_per_side={max_exec}`\n" + f"`cooldown_time={cooldown}`\n" + f"`stop_loss={stop_loss}`\n" + f"`take_profit={take_profit}`\n" + f"`trailing_stop_activation={ts_act}`\n" + f"`trailing_stop_delta={ts_delta}`\n" + f"`interval={escaped_interval}`\n" + f"`is_perpetual={str(is_perp).lower()}`\n" + f"`vwap_period={vwap_period}`\n" + f"`donchian_period={donchian_period}`\n" + f"`score_buy_threshold={score_buy}`\n" + f"`score_sell_threshold={score_sell}`\n\n" + r"_Edit: `field=value`_" + ) + + # Analysis con tripli backtick (non serve escape qui) + analysis_text = format_anti_folla_analysis(analysis) + config_text += "\n\n```\n" + analysis_text + "\n```" + + # ========== SE NON CI SONO CANDELE, MOSTRA SOLO TESTO ========== + if not candles_list: + try: + await msg.edit_text( + text=config_text, + parse_mode="MarkdownV2", # <-- CAMBIATO + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["af_wizard_message_id"] = msg.message_id + except Exception: + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=config_text, + parse_mode="MarkdownV2", # <-- CAMBIATO + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["af_wizard_message_id"] = new_msg.message_id + return + + # ========== TENTA DI GENERARE IL GRAFICO ========== + chart_bytes = None + if candles_list: + try: + from .controllers.anti_folla_v1.chart import generate_chart as af_chart + chart_bytes = af_chart(config, candles_list, current_price) + except Exception as chart_err: + logger.warning(f"AF chart generation failed: {chart_err}") + chart_bytes = None + + stored_msg_id = context.user_data.get("af_wizard_message_id") + stored_chat_id = context.user_data.get("af_wizard_chat_id", chat_id) + + if chart_bytes is not None: + chart_bytes.seek(0) + if stored_msg_id: + try: + await context.bot.delete_message(chat_id=stored_chat_id, message_id=stored_msg_id) + except Exception: + pass + try: + await msg.delete() + except Exception: + pass + new_msg = await context.bot.send_photo( + chat_id=chat_id, + photo=chart_bytes, + caption=config_text, + parse_mode="MarkdownV2", # <-- CAMBIATO + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["af_wizard_message_id"] = new_msg.message_id + context.user_data["af_wizard_chat_id"] = chat_id + else: + # No chart: edit/send as plain text message + try: + if stored_msg_id: + await context.bot.edit_message_text( + chat_id=stored_chat_id, + message_id=stored_msg_id, + text=config_text, + parse_mode="MarkdownV2", # <-- CAMBIATO + reply_markup=InlineKeyboardMarkup(keyboard), + ) + else: + raise Exception("no stored_msg_id") + except Exception: + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=config_text, + parse_mode="MarkdownV2", # <-- CAMBIATO + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["af_wizard_message_id"] = new_msg.message_id + context.user_data["af_wizard_chat_id"] = chat_id + + except Exception as e: + logger.error(f"AF final step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + try: + await msg.edit_text( + format_error_message(f"Error: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + pass + +async def handle_af_interval_change(update, context, interval: str) -> None: + context.user_data["af_candles"] = None + context.user_data["af_candles_interval"] = None + await _af_show_final_step(update, context, interval=interval) + +async def handle_af_set_strategy(update, context, strat_key: str) -> None: + """Handle Anti-Folla strategy selection from final step buttons.""" + query = update.callback_query + config = get_controller_config(context) + + analysis = context.user_data.get("af_analysis", {}) + + from .controllers.anti_folla_v1.analysis import get_af_strategy_suggestions + strats = get_af_strategy_suggestions(analysis) # ← senza NATR! + + if strat_key in strats: + selected = strats[strat_key] + + # Aggiorna i parametri specifici di Anti-Folla + config["score_buy_threshold"] = selected.get("score_buy_threshold", 50.0) + config["score_sell_threshold"] = selected.get("score_sell_threshold", -50.0) + + # Aggiorna i pesi + config["weight_vwap"] = selected.get("weight_vwap", 15) + config["weight_donchian"] = selected.get("weight_donchian", 10) + config["weight_obv"] = selected.get("weight_obv", 15) + config["weight_obi"] = selected.get("weight_obi", 20) + config["weight_volume_spike"] = selected.get("weight_volume_spike", 10) + config["weight_trade_flow"] = selected.get("weight_trade_flow", 15) + config["weight_funding"] = selected.get("weight_funding", 15) + + # TP/SL + config["take_profit"] = selected.get("take_profit", 0.03) + config["stop_loss"] = selected.get("stop_loss", 0.05) + + # Trailing stop + if "trailing_stop_activation" in selected: + if not isinstance(config.get("trailing_stop"), dict): + config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005} + config["trailing_stop"]["activation_price"] = selected["trailing_stop_activation"] + config["trailing_stop"]["trailing_delta"] = selected["trailing_stop_delta"] + + set_controller_config(context, config) + + await query.answer(f"βœ… {selected['label']} applicata") + + return await _af_show_final_step(update, context) + +async def handle_af_save(update, context) -> None: + """Save Anti-Folla V1 configuration""" + query = update.callback_query + config = get_controller_config(context) + config.pop("candles_config", None) + config.pop("manual_kill_switch", None) + # ========== FORZA IL CONNETTORE SPOT PER LE CANDELE ========== + connector = config.get("connector_name", "") + candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "") + if not candles_connector or len(candles_connector) < 3: + candles_connector = "kucoin" + + # Sovrascrivi candles_connector e candles_trading_pair + config["candles_connector"] = candles_connector + config["candles_trading_pair"] = config.get("trading_pair", "") + # ============================================================= + + config_id = config.get("id", "") + chat_id = query.message.chat_id + try: + await query.message.delete() + except Exception: + pass + status_msg = await context.bot.send_message( + chat_id=chat_id, + text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\. ", + parse_mode="MarkdownV2", + ) + try: + client, _ = await get_bots_client(chat_id, context.user_data) + await client.controllers.create_or_update_controller_config(config_id, config) + for key in list(context.user_data.keys()): + if key.endswith("_wizard_message_id") or key.endswith("_wizard_chat_id"): + context.user_data.pop(key, None) + if key.endswith("_wizard_step"): + context.user_data.pop(key, None) + context.user_data.pop("bots_state", None) + keyboard = [ + [InlineKeyboardButton("Create Another", callback_data="bots:new_anti_folla_v1")], + [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + "*Config Saved\\!*\n\nController `" + escape_markdown_v2(config_id) + "` saved successfully\\.", + parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"AF save error: {e}", exc_info=True) + keyboard = [ + [InlineKeyboardButton("Try Again", callback_data="bots:af_save")], + [InlineKeyboardButton("Back", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text(format_error_message(f"Failed to save: {str(e)}"), + parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard)) + +# Back handlers +async def handle_af_back_to_connector(update, context) -> None: + context.user_data["af_wizard_step"] = "connector_name" + await _af_show_connector_step(update, context) + +async def handle_af_back_to_pair(update, context) -> None: + context.user_data["af_wizard_step"] = "trading_pair" + await _af_show_pair_step(update, context) + +async def handle_af_back_to_leverage(update, context) -> None: + config = get_controller_config(context) + connector = config.get("connector_name", "") + is_perp = "_perpetual" in connector or "_margin" in connector + if is_perp: + context.user_data["af_wizard_step"] = "leverage" + await _af_show_leverage_step(update, context) + else: + await handle_af_back_to_pair(update, context) + +async def handle_af_back_to_amount(update, context) -> None: + context.user_data["af_wizard_step"] = "total_amount_quote" + context.user_data.pop("af_current_price", None) + context.user_data.pop("af_candles", None) + await _af_show_amount_step(update, context) + +async def handle_af_back_to_position_mode(update, context) -> None: + """Go back to position mode step""" + context.user_data["af_wizard_step"] = "position_mode" + await _af_show_position_mode_step(update, context) + +async def handle_af_pair_select( + update: Update, context: ContextTypes.DEFAULT_TYPE, trading_pair: str +) -> None: + """Handle selection of a suggested trading pair in Anti-Folla wizard""" + config = get_controller_config(context) + chat_id = update.effective_chat.id + + for key in list(context.user_data.keys()): + if key.endswith("_wizard_message_id") or key.endswith("_wizard_chat_id"): + context.user_data.pop(key, None) + if key.endswith("_wizard_step"): + context.user_data.pop(key, None) + + config["trading_pair"] = trading_pair + config["candles_trading_pair"] = trading_pair + set_controller_config(context, config) + + connector = config.get("connector_name", "") + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + + if is_perp: + context.user_data["af_wizard_step"] = "leverage" + await _af_show_leverage_step(update, context) + else: + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + set_controller_config(context, config) + context.user_data["af_wizard_step"] = "total_amount_quote" + await _af_show_amount_step(update, context) + +async def process_af_wizard_input(update, context, user_input: str) -> None: + """Process text input during Anti-Folla V1 wizard""" + step = context.user_data.get("af_wizard_step") + chat_id = update.effective_chat.id + config = get_controller_config(context) + message_id = context.user_data.get("af_wizard_message_id") + wizard_chat_id = context.user_data.get("af_wizard_chat_id", chat_id) + try: + try: + await update.message.delete() + except Exception: + pass + + if step == "trading_pair": + pair = user_input.upper().strip().replace("/", "-").replace("_", "-") + connector = config.get("connector_name", "") + + # ========== VALIDAZIONE BASE: DEVE CONTENERE IL TRATTINO ========== + if "-" not in pair: + message_id = context.user_data.get("af_wizard_message_id") + wizard_chat_id = context.user_data.get("af_wizard_chat_id", chat_id) + + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_connector")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + ] + + context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n" + + err_text = ( + r"*πŸ¦… Anti\-Folla V1 \- Step 2*" + "\n\n" + + context_text + + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n" + r"Type the pair again:" + ) + + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + else: + msg = await context.bot.send_message( + chat_id=chat_id, text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["af_wizard_message_id"] = msg.message_id + return + + # ========== 2. VALIDAZIONE SULL'EXCHANGE (CON SUGGERIMENTI) ========== + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = ( + await validate_trading_pair(context.user_data, client, connector, pair) + ) + + if not is_valid: + message_id = context.user_data.get("af_wizard_message_id") + wizard_chat_id = context.user_data.get("af_wizard_chat_id", chat_id) + + keyboard = [] + for sugg in suggestions[:4]: + keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:af_pair_select:{sugg}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_connector")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n" + + err_text = ( + r"*πŸ¦… Anti\-Folla V1 \- Step 2*" + "\n\n" + + context_text + + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n" + r"*Did you mean?*" + ) + + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + else: + msg = await context.bot.send_message( + chat_id=chat_id, text=err_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["af_wizard_message_id"] = msg.message_id + return + + if correct_pair: + pair = correct_pair + + config["trading_pair"] = pair + config["candles_trading_pair"] = pair + for key in ["af_current_price", "af_candles", "af_candles_interval"]: + context.user_data.pop(key, None) + set_controller_config(context, config) + + connector = config.get("connector_name", "") + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + message_id = context.user_data.get("af_wizard_message_id") + wizard_chat_id = context.user_data.get("af_wizard_chat_id", chat_id) + + if is_perp: + context.user_data["af_wizard_step"] = "leverage" + keyboard = [ + [ + InlineKeyboardButton("1x", callback_data="bots:af_leverage:1"), + InlineKeyboardButton("5x", callback_data="bots:af_leverage:5"), + InlineKeyboardButton("10x", callback_data="bots:af_leverage:10"), + ], + [ + InlineKeyboardButton("20x", callback_data="bots:af_leverage:20"), + InlineKeyboardButton("50x", callback_data="bots:af_leverage:50"), + InlineKeyboardButton("75x", callback_data="bots:af_leverage:75"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + text = ( + r"*πŸ¦… Anti\-Folla V1 \- Step 3/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n\n" + r"⚑ *Select Leverage*" + "\n" + r"_Or type a value \(e\.g\. 20\)_" + ) + else: + config["leverage"] = 1 + set_controller_config(context, config) + context.user_data["af_wizard_step"] = "total_amount_quote" + context.user_data["bots_state"] = "af_wizard_input" + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:af_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:af_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:af_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:af_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:af_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:af_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + text = ( + r"*πŸ¦… Anti\-Folla V1 \- Step 3/4*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n\n" + r"πŸ’° *Total Amount \(USDT\)*" + "\n" + r"_Select or type an amount:_" + ) + + try: + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["af_wizard_message_id"] = message_id + except Exception: + pass + return + + elif step == "leverage": + val = int(float(user_input.strip().lower().replace("x", ""))) + config["leverage"] = val + set_controller_config(context, config) + context.user_data["af_wizard_step"] = "total_amount_quote" + message_id = context.user_data.get("af_wizard_message_id") + wizard_chat_id = context.user_data.get("af_wizard_chat_id", chat_id) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:af_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:af_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:af_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:af_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:af_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:af_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_leverage"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + text = ( + r"*πŸ¦… Anti\-Folla V1 \- Step 4/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}` \\| ⚑ `{val}x`" + "\n\n" + r"πŸ’° *Total Amount \(USDT\)*" + "\n" + r"_Select or type an amount:_" + ) + context.user_data["bots_state"] = "af_wizard_input" + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + elif step == "total_amount_quote": + amount = float(user_input.strip().replace("$", "").replace(",", "")) + config["total_amount_quote"] = amount + set_controller_config(context, config) + context.user_data["af_wizard_step"] = "final" + wizard_chat_id = context.user_data.get("af_wizard_chat_id", chat_id) + pair = config.get("trading_pair", "") + tmp = await context.bot.send_message( + chat_id=wizard_chat_id, + text=r"*πŸ¦… Anti\-Folla V1*" + "\n\n" + f"⏳ Loading chart for `{escape_markdown_v2(pair)}`\\.\\.\\. ", + parse_mode="MarkdownV2", + ) + context.user_data["af_wizard_message_id"] = tmp.message_id + await _af_show_final_step(update, context) + + elif step == "final": + # Handle field=value edits + if "=" in user_input: + for line in user_input.strip().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() + try: + if field in ("total_amount_quote", "stop_loss", "take_profit", + "volume_spike_threshold", "obi_depth_percentage", + "obi_buy_threshold", "obi_sell_threshold", + "score_buy_threshold", "score_sell_threshold", + "weight_vwap", "weight_donchian", "weight_obv", + "weight_obi", "weight_volume_spike", "weight_trade_flow", + "weight_funding", "trailing_stop_activation", "trailing_stop_delta"): + val = float(value) + if field == "trailing_stop_activation": + if not isinstance(config.get("trailing_stop"), dict): + config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005} + config["trailing_stop"]["activation_price"] = val + elif field == "trailing_stop_delta": + if not isinstance(config.get("trailing_stop"), dict): + config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005} + config["trailing_stop"]["trailing_delta"] = val + else: + config[field] = val + elif field in ("leverage", "max_executors_per_side", "cooldown_time", + "take_profit_order_type", "vwap_period", "donchian_period", + "atr_period", "obv_divergence_lookback"): + config[field] = int(float(value)) + elif field in ("is_perpetual", "enable_order_book_imbalance"): + config[field] = value.lower() in ("true", "yes", "1") + elif field == "interval": + config["interval"] = value + context.user_data.pop("af_candles", None) + context.user_data["af_chart_interval"] = value + else: + config[field] = value + except Exception: + pass + set_controller_config(context, config) + + except Exception as e: + logger.error(f"AF wizard input error: {e}", exc_info=True) + + + + +# ============================================ +# FUNDING RATE ARBITRAGE WIZARD (basato su arbitrage_controller) +# ============================================ +# Steps: connector1 β†’ pair1 β†’ connector2 β†’ pair2 β†’ amount β†’ final+save +# Prefisso handler: fra_ + +async def show_new_funding_rate_arb_form(update, context) -> None: + """Start the Funding Rate Arbitrage wizard - Step 1: Exchange 1""" + query = update.callback_query + chat_id = update.effective_chat.id + + for key in ["fra_price_1", "fra_price_2"]: + context.user_data.pop(key, None) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + 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}") + + init_new_controller_config(context, "funding_rate_arb") + context.user_data["bots_state"] = "fra_wizard" + context.user_data["fra_wizard_step"] = "connector_1" + context.user_data["fra_wizard_message_id"] = query.message.message_id + context.user_data["fra_wizard_chat_id"] = query.message.chat_id + await _fra_show_connector_step(update, context, exchange_num=1) + +async def _fra_show_connector_step(update, context, exchange_num: int, target_message_id: int = None) -> None: + """Show connector selection for exchange 1 or 2""" + query = update.callback_query + use_message_id = target_message_id + chat_id = update.effective_chat.id + config = get_controller_config(context) + + if not query and not use_message_id: + logger.error("_fra_show_connector_step called without callback_query and without target_message_id") + return + + if exchange_num == 1: + step = 1 + emoji = "1️⃣" + role = "Exchange 1 \\(Long leg\\)" + else: + step = 3 + emoji = "2️⃣" + role = "Exchange 2 \\(Short leg\\)" + + header = "" + if exchange_num == 2: + ep1 = config.get("exchange_pair_1", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + header = f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n\n" + + try: + client, server_name = await get_bots_client(chat_id, context.user_data) + cex_connectors = await get_available_cex_connectors( + context.user_data, client, server_name=server_name + ) + + keyboard = [] + if cex_connectors: + keyboard.append([InlineKeyboardButton("β€” CEX β€”", callback_data="bots:noop")]) + row = [] + for c in cex_connectors: + row.append(InlineKeyboardButton(c, callback_data=f"bots:fra_connector_{exchange_num}:{c}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + back_cb = "bots:main_menu" if exchange_num == 1 else "bots:fra_back_to_pair_1" + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data=back_cb), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + message_text = ( + rf"*πŸ’° Funding Rate Arbitrage \- Step {step}/6*" + "\n\n" + r"Arbitrage between funding rates across exchanges\." + "\n\n" + r"─────────────────────────" + "\n\n" + + header + + rf"*{emoji} Select {role}:*" + ) + + if query and query.message: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + elif use_message_id: + wizard_chat_id = context.user_data.get("fra_wizard_chat_id", chat_id) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"FRA connector step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + error_text = format_error_message(f"Error: {str(e)}") + if query and query.message: + await query.message.edit_text( + error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + elif use_message_id: + wizard_chat_id = context.user_data.get("fra_wizard_chat_id", chat_id) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_fra_wizard_connector_1(update, context, connector: str) -> None: + config = get_controller_config(context) + if "exchange_pair_1" not in config: + config["exchange_pair_1"] = {} + config["exchange_pair_1"]["connector_name"] = connector + # Auto-set rate_connector (opzionale per funding rate arb) + config["rate_connector"] = connector.replace("_perpetual", "").replace("_spot", "") + set_controller_config(context, config) + context.user_data["fra_wizard_step"] = "pair_1" + await _fra_show_pair_step(update, context, exchange_num=1) + + +async def handle_fra_wizard_connector_2(update, context, connector: str) -> None: + config = get_controller_config(context) + if "exchange_pair_2" not in config: + config["exchange_pair_2"] = {} + config["exchange_pair_2"]["connector_name"] = connector + set_controller_config(context, config) + context.user_data["fra_wizard_step"] = "pair_2" + await _fra_show_pair_step(update, context, exchange_num=2) + +async def _fra_show_pair_step(update, context, exchange_num: int, target_message_id: int = None) -> None: + """Show trading pair input for exchange 1 or 2""" + query = update.callback_query + use_message_id = target_message_id + config = get_controller_config(context) + + if not query and not use_message_id: + logger.error("_fra_show_pair_step called without callback_query and without target_message_id") + return + + ep_key = f"exchange_pair_{exchange_num}" + connector = config.get(ep_key, {}).get("connector_name", "") + + context.user_data["bots_state"] = "fra_wizard_input" + context.user_data["fra_wizard_step"] = f"pair_{exchange_num}" + + step = 2 if exchange_num == 1 else 4 + emoji = "1️⃣" if exchange_num == 1 else "2️⃣" + + header = "" + if exchange_num == 2: + ep1 = config.get("exchange_pair_1", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + header = f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n\n" + else: + c1 = config.get("exchange_pair_1", {}).get("connector_name", "") + header = f"1️⃣ `{escape_markdown_v2(c1)}`\n\n" + + existing_configs = context.user_data.get("controller_configs_list", []) + recent_pairs = [] + seen = set() + for cfg in reversed(existing_configs): + ep = cfg.get(ep_key, {}) + pair = ep.get("trading_pair", "") if isinstance(ep, dict) else "" + if pair and pair not in seen: + seen.add(pair) + recent_pairs.append(pair) + if len(recent_pairs) >= 6: + break + + if exchange_num == 2: + p1 = config.get("exchange_pair_1", {}).get("trading_pair", "") + if p1 and p1 not in seen: + recent_pairs.insert(0, p1) + + keyboard = [] + if recent_pairs: + row = [] + for pair in recent_pairs[:6]: + row.append(InlineKeyboardButton(pair, callback_data=f"bots:fra_pair_{exchange_num}:{pair}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + back_cb = f"bots:fra_back_to_connector_{exchange_num}" + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data=back_cb), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + message_text = ( + rf"*πŸ’° Funding Rate Arbitrage \- Step {step}/6*" + "\n\n" + + header + + rf"*{emoji} Trading Pair on* `{escape_markdown_v2(connector)}`:" + "\n\n" + r"_e\.g\. SOL\-USDT_" + "\n\n" + r"Select or type a pair:" + ) + + if query and query.message: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + elif use_message_id: + wizard_chat_id = context.user_data.get("fra_wizard_chat_id", update.effective_chat.id) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_fra_wizard_pair_1(update, context, pair: str) -> None: + config = get_controller_config(context) + if "exchange_pair_1" not in config: + config["exchange_pair_1"] = {} + config["exchange_pair_1"]["trading_pair"] = pair.upper() + set_controller_config(context, config) + context.user_data["fra_wizard_step"] = "connector_2" + await _fra_show_connector_step(update, context, exchange_num=2) + + +async def handle_fra_wizard_pair_2(update, context, pair: str) -> None: + config = get_controller_config(context) + if "exchange_pair_2" not in config: + config["exchange_pair_2"] = {} + config["exchange_pair_2"]["trading_pair"] = pair.upper() + # Auto-set quote_conversion_asset + ep1 = config.get("exchange_pair_1", {}) + p1 = ep1.get("trading_pair", "") + if p1: + quote = p1.split("-")[1] if "-" in p1 else "USDT" + else: + quote = pair.split("-")[1] if "-" in pair else "USDT" + config["quote_conversion_asset"] = quote + set_controller_config(context, config) + context.user_data["fra_wizard_step"] = "total_amount_quote" + await _fra_show_amount_step(update, context) + + +async def _fra_show_amount_step(update, context) -> None: + """FRA Step 5: Enter Total Amount""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + + ep1 = config.get("exchange_pair_1", {}) + ep2 = config.get("exchange_pair_2", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + c2 = ep2.get("connector_name", "") + p2 = ep2.get("trading_pair", "") + + context.user_data["bots_state"] = "fra_wizard_input" + context.user_data["fra_wizard_step"] = "total_amount_quote" + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:fra_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:fra_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:fra_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:fra_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:fra_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:fra_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:fra_back_to_pair_2"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + message_text = ( + r"*πŸ’° Funding Rate Arbitrage \- Step 5/6*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"2️⃣ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n" + r"πŸ’° *Total Amount \(split equally per leg\)*" + "\n" + r"_Select or type an amount:_" + ) + + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + 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_fra_wizard_amount(update, context, amount: float) -> None: + query = update.callback_query + config = get_controller_config(context) + config["total_amount_quote"] = amount + set_controller_config(context, config) + + ep1 = config.get("exchange_pair_1", {}) + pair = ep1.get("trading_pair", "") + await query.message.edit_text( + r"*πŸ’° Funding Rate Arbitrage \- New Config*" + "\n\n" + f"⏳ Fetching configuration for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + context.user_data["fra_wizard_step"] = "final" + await _fra_show_final_step(update, context) + + +async def _fra_show_final_step(update, context) -> None: + """FRA Final Step: Show config with ONLY fields supported by the controller""" + query = update.callback_query + msg = query.message if query else update.message + chat_id = update.effective_chat.id + config = get_controller_config(context) + + ep1 = config.get("exchange_pair_1", {}) + ep2 = config.get("exchange_pair_2", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + c2 = ep2.get("connector_name", "") + p2 = ep2.get("trading_pair", "") + total_amount = config.get("total_amount_quote", 100) + entry_threshold = config.get("entry_threshold", 0.000025) + exit_threshold = config.get("exit_threshold", 0.000005) + sl_global = config.get("sl_global", 0.03) + tp_global = config.get("tp_global", 0.05) + funding_check_interval = config.get("funding_check_interval", 300) + executor_refresh_time = config.get("executor_refresh_time", 60) + funding_interval_a_hours = config.get("funding_interval_a_hours", None) + funding_interval_b_hours = config.get("funding_interval_b_hours", None) + + if "controller_type" not in config: + config["controller_type"] = "generic" + set_controller_config(context, config) + + if not config.get("id"): + existing_configs = context.user_data.get("controller_configs_list", []) + from .controllers.funding_rate_arb.config import generate_id as fra_generate_id + config["id"] = fra_generate_id(config, existing_configs) + set_controller_config(context, config) + + context.user_data["bots_state"] = "fra_wizard_input" + context.user_data["fra_wizard_step"] = "final" + + keyboard = [ + [InlineKeyboardButton("πŸ’Ύ Save Config", callback_data="bots:fra_save")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:fra_back_to_amount"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + config_id = config.get("id", "") + + config_text = ( + r"*πŸ’° Funding Rate Arbitrage \- Final Review*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"2️⃣ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n" + f"`id={escape_markdown_v2(config_id)}`\n" + f"`total_amount_quote={total_amount:.0f}`\n" + f"`entry_threshold={entry_threshold}`\n" + f"`exit_threshold={exit_threshold}`\n" + f"`sl_global={sl_global}`\n" + f"`tp_global={tp_global}`\n" + f"`funding_check_interval={funding_check_interval}`\n" + f"`executor_refresh_time={executor_refresh_time}`\n" + f"`funding_interval_a_hours={funding_interval_a_hours}`\n" + f"`funding_interval_b_hours={funding_interval_b_hours}`\n\n" + r"_Edit: `field=value`_" + ) + + try: + await msg.edit_text( + config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + try: + await msg.delete() + except Exception: + pass + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["fra_wizard_message_id"] = new_msg.message_id + context.user_data["fra_wizard_chat_id"] = chat_id + + +async def handle_fra_save(update, context) -> None: + """Save Funding Rate Arbitrage config - pulisce i campi non supportati""" + query = update.callback_query + config = get_controller_config(context) + + # Campi supportati dal controller + supported_fields = [ + "id", + "controller_name", + "controller_type", + "exchange_pair_1", + "exchange_pair_2", + "total_amount_quote", + "entry_threshold", + "exit_threshold", + "sl_global", + "tp_global", + "funding_check_interval", + "executor_refresh_time", + "funding_interval_a_hours", + "funding_interval_b_hours", + "leverage", + "position_mode", + ] + + if "controller_type" not in config: + config["controller_type"] = "generic" + + keys_to_remove = [k for k in list(config.keys()) if k not in supported_fields] + for key in keys_to_remove: + config.pop(key, None) + + for ep_key in ["exchange_pair_1", "exchange_pair_2"]: + if ep_key in config and isinstance(config[ep_key], dict): + ep = config[ep_key] + config[ep_key] = { + "connector_name": ep.get("connector_name", ""), + "trading_pair": ep.get("trading_pair", "") + } + + config_id = config.get("id", "") + chat_id = query.message.chat_id + + try: + await query.message.delete() + except Exception: + pass + + status_msg = await context.bot.send_message( + chat_id=chat_id, + text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + await client.controllers.create_or_update_controller_config(config_id, config) + + for key in ["fra_wizard_step", "fra_wizard_message_id", "fra_wizard_chat_id", + "fra_price_1", "fra_price_2"]: + context.user_data.pop(key, None) + context.user_data.pop("bots_state", None) + + keyboard = [ + [InlineKeyboardButton("Create Another", callback_data="bots:new_funding_rate_arb")], + [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + "*Config Saved\\!*\n\n" + "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"FRA save error: {e}", exc_info=True) + keyboard = [ + [InlineKeyboardButton("Try Again", callback_data="bots:fra_save")], + [InlineKeyboardButton("Back", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + format_error_message(f"Failed to save: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +# Back handlers +async def handle_fra_back_to_connector_1(update, context) -> None: + context.user_data["fra_wizard_step"] = "connector_1" + await _fra_show_connector_step(update, context, exchange_num=1) + + +async def handle_fra_back_to_connector_2(update, context) -> None: + context.user_data["fra_wizard_step"] = "connector_2" + await _fra_show_connector_step(update, context, exchange_num=2) + + +async def handle_fra_back_to_pair_1(update, context) -> None: + context.user_data["fra_wizard_step"] = "pair_1" + await _fra_show_pair_step(update, context, exchange_num=1) + + +async def handle_fra_back_to_pair_2(update, context) -> None: + context.user_data["fra_wizard_step"] = "pair_2" + await _fra_show_pair_step(update, context, exchange_num=2) + + +async def handle_fra_back_to_amount(update, context) -> None: + context.user_data["fra_wizard_step"] = "total_amount_quote" + context.user_data.pop("fra_price_1", None) + context.user_data.pop("fra_price_2", None) + await _fra_show_amount_step(update, context) + + +async def _fra_show_amount_step(update, context, target_message_id: int = None) -> None: + """FRA Step 5: Enter Total Amount (versione per input testuale)""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + + use_message_id = target_message_id + if not query and not use_message_id: + logger.error("_fra_show_amount_step called without callback_query and without target_message_id") + return + + if not query and use_message_id: + wizard_chat_id = context.user_data.get("fra_wizard_chat_id", chat_id) + + ep1 = config.get("exchange_pair_1", {}) + ep2 = config.get("exchange_pair_2", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + c2 = ep2.get("connector_name", "") + p2 = ep2.get("trading_pair", "") + + context.user_data["bots_state"] = "fra_wizard_input" + context.user_data["fra_wizard_step"] = "total_amount_quote" + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:fra_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:fra_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:fra_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:fra_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:fra_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:fra_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:fra_back_to_pair_2"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + message_text = ( + r"*πŸ’° Funding Rate Arbitrage \- Step 5/6*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"2️⃣ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n" + r"πŸ’° *Total Amount \(split equally per leg\)*" + "\n" + r"_Select or type an amount:_" + ) + + if query and query.message: + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + 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), + ) + elif use_message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"Error editing message in _fra_show_amount_step: {e}") + new_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["fra_wizard_message_id"] = new_msg.message_id + + +async def process_fra_wizard_input(update, context, user_input: str) -> None: + """Process text input during Funding Rate Arbitrage wizard""" + step = context.user_data.get("fra_wizard_step") + chat_id = update.effective_chat.id + config = get_controller_config(context) + message_id = context.user_data.get("fra_wizard_message_id") + wizard_chat_id = context.user_data.get("fra_wizard_chat_id", chat_id) + + try: + try: + await update.message.delete() + except Exception: + pass + + if step == "pair_1": + pair = user_input.upper().strip().replace("/", "-").replace("_", "-") + connector = config.get("exchange_pair_1", {}).get("connector_name", "") + + if "-" not in pair: + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data="bots:fra_back_to_connector_1")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + ] + error_text = ( + r"*πŸ’° Funding Rate Arbitrage \- Step 2/6*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(connector)}`\n\n" + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. SOL\-USDT\)*" + "\n\n" + r"Type the pair again:" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = await validate_trading_pair( + context.user_data, client, connector, pair + ) + + if not is_valid: + keyboard = [] + for sugg in suggestions[:4]: + keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:fra_pair_1:{sugg}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:fra_back_to_connector_1")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + error_text = ( + r"*πŸ’° Funding Rate Arbitrage \- Step 2/6*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(connector)}`\n\n" + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n" + r"*Did you mean?*" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + if correct_pair: + pair = correct_pair + + if "exchange_pair_1" not in config: + config["exchange_pair_1"] = {} + config["exchange_pair_1"]["trading_pair"] = pair + set_controller_config(context, config) + + # VAI AL CONNECTOR 2 (STEP 3) - USA target_message_id + context.user_data["fra_wizard_step"] = "connector_2" + await _fra_show_connector_step(update, context, exchange_num=2, target_message_id=message_id) + return + + elif step == "pair_2": + pair = user_input.upper().strip().replace("/", "-").replace("_", "-") + connector = config.get("exchange_pair_2", {}).get("connector_name", "") + # DEFINISCI c1 e p1 PRIMA di usarle (fuori dal blocco if) + ep1 = config.get("exchange_pair_1", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + if "-" not in pair: + ep1 = config.get("exchange_pair_1", {}) + c1 = ep1.get("connector_name", "") + p1 = ep1.get("trading_pair", "") + + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data="bots:fra_back_to_connector_2")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + ] + error_text = ( + r"*πŸ’° Funding Rate Arbitrage \- Step 4/6*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"2️⃣ `{escape_markdown_v2(connector)}`\n\n" + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. SOL\-USDT\)*" + "\n\n" + r"Type the pair again:" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + is_cex = "/" not in connector + if is_cex: + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = await validate_trading_pair( + context.user_data, client, connector, pair + ) + + if not is_valid: + keyboard = [] + for sugg in suggestions[:4]: + keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:fra_pair_2:{sugg}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:fra_back_to_connector_2")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + error_text = ( + r"*πŸ’° Funding Rate Arbitrage \- Step 4/6*" + "\n\n" + f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"2️⃣ `{escape_markdown_v2(connector)}`\n\n" + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n" + r"*Did you mean?*" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + if correct_pair: + pair = correct_pair + + if "exchange_pair_2" not in config: + config["exchange_pair_2"] = {} + config["exchange_pair_2"]["trading_pair"] = pair + + ep1 = config.get("exchange_pair_1", {}) + p1 = ep1.get("trading_pair", "") + if p1: + quote = p1.split("-")[1] if "-" in p1 else "USDT" + else: + quote = pair.split("-")[1] if "-" in pair else "USDT" + config["quote_conversion_asset"] = quote + set_controller_config(context, config) + + # VAI ALL'AMOUNT (STEP 5) - USA target_message_id + context.user_data["fra_wizard_step"] = "total_amount_quote" + await _fra_show_amount_step(update, context, target_message_id=message_id) + return + elif step == "total_amount_quote": + try: + amount = float(user_input.strip().replace("$", "").replace(",", "")) + config["total_amount_quote"] = amount + set_controller_config(context, config) + context.user_data["fra_wizard_step"] = "final" + pair = config.get("exchange_pair_1", {}).get("trading_pair", "") + if message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=r"*πŸ’° Funding Rate Arbitrage \- New Config*" + "\n\n" + f"⏳ Fetching configuration for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + except Exception: + pass + else: + tmp_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=r"*πŸ’° Funding Rate Arbitrage \- New Config*" + "\n\n" + f"⏳ Fetching configuration for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + context.user_data["fra_wizard_message_id"] = tmp_msg.message_id + + await _fra_show_final_step(update, context) + + except ValueError: + keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:fra_back_to_amount")]] + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=r"*πŸ’° Funding Rate Arbitrage \- Step 5*" + "\n\n" + r"❌ *Invalid amount*" + "\n\n" + r"Enter a positive number \(e\.g\. 500\):", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + elif step == "final": + if "=" in user_input: + for line in user_input.strip().split("\n"): + if "=" not in line: + continue + field, value = line.split("=", 1) + field = field.strip().lower() + value = value.strip() + try: + if field in ("total_amount_quote", "entry_threshold", "exit_threshold", "sl_global", "tp_global"): + config[field] = float(value) + elif field in ("funding_check_interval", "executor_refresh_time", "leverage"): + config[field] = int(float(value)) + elif field in ("funding_interval_a_hours", "funding_interval_b_hours"): + config[field] = int(value) if value not in ("null", "None", "") else None + else: + config[field] = value + except Exception: + pass + set_controller_config(context, config) + await _fra_show_final_step(update, context) + + except Exception as e: + logger.error(f"FRA wizard input error: {e}", exc_info=True) + +# ============================================ +# DELTA NEUTRAL MM WIZARD (basato su funding_rate_arb) +# ============================================ +# Steps: maker_connector β†’ maker_pair β†’ hedge_connector β†’ hedge_pair β†’ amount β†’ final+save +# Prefisso handler: dnmm_ + +async def show_new_delta_neutral_mm_form(update, context) -> None: + """Start the Delta Neutral MM wizard - Step 1: Maker Exchange""" + query = update.callback_query + chat_id = update.effective_chat.id + + for key in ["dnmm_price_maker", "dnmm_price_hedge"]: + context.user_data.pop(key, None) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + 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}") + + init_new_controller_config(context, "delta_neutral_mm") + context.user_data["bots_state"] = "dnmm_wizard" + context.user_data["dnmm_wizard_step"] = "maker_connector" + context.user_data["dnmm_wizard_message_id"] = query.message.message_id + context.user_data["dnmm_wizard_chat_id"] = query.message.chat_id + await _dnmm_show_connector_step(update, context, role="maker") + +async def _dnmm_show_connector_step(update, context, role: str, target_message_id: int = None) -> None: + """Show connector selection for maker or hedge""" + query = update.callback_query + use_message_id = target_message_id + chat_id = update.effective_chat.id + config = get_controller_config(context) + + if not query and not use_message_id: + logger.error("_dnmm_show_connector_step called without callback_query and without target_message_id") + return + + is_maker = role == "maker" + step = 1 if is_maker else 3 + emoji = "🏭" if is_maker else "πŸ›‘οΈ" + role_label = "Maker \\(Spot\\)" if is_maker else "Hedge \\(Perpetual\\)" + + header = "" + if not is_maker: + maker = config.get("connector_pair_maker", {}) + c1 = maker.get("connector_name", "") + p1 = maker.get("trading_pair", "") + if c1 and p1: + header = f"🏭 `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n\n" + + try: + client, server_name = await get_bots_client(chat_id, context.user_data) + + # Ottieni TUTTI i connector configurati + all_connectors = await get_available_cex_connectors( + context.user_data, client, server_name=server_name + ) + + # Filtra in base al ruolo + if is_maker: + # Maker: solo connector SPOT (non perpetual) + available_connectors = [c for c in all_connectors if not c.endswith("_perpetual")] + else: + # Hedge: solo connector PERPETUAL + available_connectors = [c for c in all_connectors if c.endswith("_perpetual")] + + if not available_connectors: + keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]] + error_text = ( + f"⚠️ *No {role_label} connectors available*\n\n" + f"You need to configure API keys for a {role_label} exchange first." + ) + if query and query.message: + await query.message.edit_text( + error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + elif use_message_id: + wizard_chat_id = context.user_data.get("dnmm_wizard_chat_id", chat_id) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + # Costruisci keyboard (come in FRA) + keyboard = [] + if available_connectors: + keyboard.append([InlineKeyboardButton("β€” CEX β€”", callback_data="bots:noop")]) + row = [] + for c in available_connectors: + row.append(InlineKeyboardButton(c, callback_data=f"bots:dnmm_{role}_connector:{c}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + back_cb = "bots:main_menu" if is_maker else "bots:dnmm_back_to_maker_pair" + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data=back_cb), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + message_text = ( + rf"*⚑ Delta Neutral MM \- Step {step}/6*" + "\n\n" + r"Market making on spot with delta hedging on perpetual\." + "\n\n" + r"─────────────────────────" + "\n\n" + + header + + rf"*{emoji} Select {role_label}:*" + ) + + if query and query.message: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + elif use_message_id: + wizard_chat_id = context.user_data.get("dnmm_wizard_chat_id", chat_id) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"DNMM connector step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + error_text = format_error_message(f"Error: {str(e)}") + if query and query.message: + await query.message.edit_text( + error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + elif use_message_id: + wizard_chat_id = context.user_data.get("dnmm_wizard_chat_id", chat_id) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_dnmm_wizard_maker_connector(update, context, connector: str) -> None: + config = get_controller_config(context) + if "connector_pair_maker" not in config: + config["connector_pair_maker"] = {} + config["connector_pair_maker"]["connector_name"] = connector + set_controller_config(context, config) + context.user_data["dnmm_wizard_step"] = "maker_pair" + await _dnmm_show_pair_step(update, context, role="maker") + + +async def handle_dnmm_wizard_hedge_connector(update, context, connector: str) -> None: + config = get_controller_config(context) + if "connector_pair_hedge" not in config: + config["connector_pair_hedge"] = {} + config["connector_pair_hedge"]["connector_name"] = connector + set_controller_config(context, config) + context.user_data["dnmm_wizard_step"] = "hedge_pair" + await _dnmm_show_pair_step(update, context, role="hedge") + + +async def _dnmm_show_pair_step(update, context, role: str) -> None: + """Show trading pair input for maker or hedge""" + query = update.callback_query + config = get_controller_config(context) + + cp_key = f"connector_pair_{role}" + connector = config.get(cp_key, {}).get("connector_name", "") + + context.user_data["bots_state"] = "dnmm_wizard_input" + context.user_data["dnmm_wizard_step"] = f"{role}_pair" + + step = 2 if role == "maker" else 4 + emoji = "🏭 Maker" if role == "maker" else "πŸ›‘οΈ Hedge" + + header = "" + if role == "hedge": + maker = config.get("connector_pair_maker", {}) + c1 = maker.get("connector_name", "") + p1 = maker.get("trading_pair", "") + header = f"🏭 `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n\n" + else: + c1 = config.get("connector_pair_maker", {}).get("connector_name", "") + header = f"🏭 `{escape_markdown_v2(c1)}`\n\n" + + existing_configs = context.user_data.get("controller_configs_list", []) + recent_pairs = [] + seen = set() + for cfg in reversed(existing_configs): + cp = cfg.get(cp_key, {}) + pair = cp.get("trading_pair", "") if isinstance(cp, dict) else "" + if pair and pair not in seen: + seen.add(pair) + recent_pairs.append(pair) + if len(recent_pairs) >= 6: + break + + if role == "hedge": + p1 = config.get("connector_pair_maker", {}).get("trading_pair", "") + if p1 and p1 not in seen: + recent_pairs.insert(0, p1) + + keyboard = [] + if recent_pairs: + row = [] + for pair in recent_pairs[:6]: + row.append(InlineKeyboardButton(pair, callback_data=f"bots:dnmm_{role}_pair:{pair}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + back_cb = f"bots:dnmm_back_to_{role}_connector" + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data=back_cb), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + await query.message.edit_text( + rf"*⚑ Delta Neutral MM \- Step {step}/6*" + "\n\n" + + header + + rf"*{emoji} Trading Pair on* `{escape_markdown_v2(connector)}`:" + "\n\n" + r"_e\.g\. SOL\-USDT_" + "\n\n" + r"Select or type a pair:", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_dnmm_wizard_maker_pair(update, context, pair: str) -> None: + config = get_controller_config(context) + if "connector_pair_maker" not in config: + config["connector_pair_maker"] = {} + config["connector_pair_maker"]["trading_pair"] = pair.upper() + set_controller_config(context, config) + context.user_data["dnmm_wizard_step"] = "hedge_connector" + await _dnmm_show_connector_step(update, context, role="hedge") + + +async def handle_dnmm_wizard_hedge_pair(update, context, pair: str) -> None: + config = get_controller_config(context) + if "connector_pair_hedge" not in config: + config["connector_pair_hedge"] = {} + config["connector_pair_hedge"]["trading_pair"] = pair.upper() + set_controller_config(context, config) + context.user_data["dnmm_wizard_step"] = "total_amount_quote" + await _dnmm_show_amount_step(update, context) + + +async def _dnmm_show_amount_step(update, context) -> None: + """DNMM Step 5: Enter Total Amount""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + + maker = config.get("connector_pair_maker", {}) + hedge = config.get("connector_pair_hedge", {}) + c1 = maker.get("connector_name", "") + p1 = maker.get("trading_pair", "") + c2 = hedge.get("connector_name", "") + p2 = hedge.get("trading_pair", "") + + context.user_data["bots_state"] = "dnmm_wizard_input" + context.user_data["dnmm_wizard_step"] = "total_amount_quote" + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:dnmm_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:dnmm_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:dnmm_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:dnmm_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:dnmm_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:dnmm_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:dnmm_back_to_hedge_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + message_text = ( + r"*⚑ Delta Neutral MM \- Step 5/6*" + "\n\n" + f"🏭 `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"πŸ›‘οΈ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n" + r"πŸ’° *Total Amount \(split between legs\)*" + "\n" + r"_Select or type an amount:_" + ) + + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + 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_dnmm_wizard_amount(update, context, amount: float) -> None: + query = update.callback_query + config = get_controller_config(context) + config["total_amount_quote"] = amount + set_controller_config(context, config) + + maker = config.get("connector_pair_maker", {}) + pair = maker.get("trading_pair", "") + await query.message.edit_text( + r"*⚑ Delta Neutral MM \- New Config*" + "\n\n" + f"⏳ Fetching configuration for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + context.user_data["dnmm_wizard_step"] = "final" + await _dnmm_show_final_step(update, context) + + +async def _dnmm_show_final_step(update, context) -> None: + """DNMM Final Step: Show config with ONLY fields supported by the controller""" + query = update.callback_query + msg = query.message if query else update.message + chat_id = update.effective_chat.id + config = get_controller_config(context) + + maker = config.get("connector_pair_maker", {}) + hedge = config.get("connector_pair_hedge", {}) + c1 = maker.get("connector_name", "") + p1 = maker.get("trading_pair", "") + c2 = hedge.get("connector_name", "") + p2 = hedge.get("trading_pair", "") + total_amount = config.get("total_amount_quote", 100) + order_amount_quote = config.get("order_amount_quote", 15) + buy_spreads = config.get("buy_spreads", "1.0,2.0,3.0") + sell_spreads = config.get("sell_spreads", "1.0,2.0,3.0") + order_refresh_time = config.get("order_refresh_time", 30) + hedge_threshold_quote = config.get("hedge_threshold_quote", 10) + max_delta_quote = config.get("max_delta_quote", 50) + sl_global = config.get("sl_global", 0.03) + tp_global = config.get("tp_global", 0.05) + hedge_position_timeout = config.get("hedge_position_timeout", 3600) + maker_tp_multiplier = config.get("maker_tp_multiplier", 1.0) + leverage = config.get("leverage", 1) + position_mode = config.get("position_mode", "HEDGE") + macd_fast = config.get("macd_fast", 21) + macd_slow = config.get("macd_slow", 42) + macd_signal = config.get("macd_signal", 9) + natr_length = config.get("natr_length", 14) + interval = config.get("interval", "3m") + + if "controller_type" not in config: + config["controller_type"] = "generic" + set_controller_config(context, config) + + if not config.get("id"): + existing_configs = context.user_data.get("controller_configs_list", []) + from .controllers.delta_neutral_mm.config import generate_id as dnmm_generate_id + config["id"] = dnmm_generate_id(config, existing_configs) + set_controller_config(context, config) + + context.user_data["bots_state"] = "dnmm_wizard_input" + context.user_data["dnmm_wizard_step"] = "final" + + keyboard = [ + [InlineKeyboardButton("πŸ’Ύ Save Config", callback_data="bots:dnmm_save")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:dnmm_back_to_amount"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + config_id = config.get("id", "") + + # Converti liste in stringhe per visualizzazione + buy_spreads_str = ",".join(str(s) for s in buy_spreads) if isinstance(buy_spreads, list) else buy_spreads + sell_spreads_str = ",".join(str(s) for s in sell_spreads) if isinstance(sell_spreads, list) else sell_spreads + + config_text = ( + r"*⚑ Delta Neutral MM \- Final Review*" + "\n\n" + f"🏭 `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"πŸ›‘οΈ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n" + f"`id={escape_markdown_v2(config_id)}`\n" + f"`total_amount_quote={total_amount:.0f}`\n" + f"`order_amount_quote={order_amount_quote:.0f}`\n" + f"`buy_spreads={escape_markdown_v2(buy_spreads_str)}`\n" + f"`sell_spreads={escape_markdown_v2(sell_spreads_str)}`\n" + f"`order_refresh_time={order_refresh_time}`\n" + f"`hedge_threshold_quote={hedge_threshold_quote}`\n" + f"`max_delta_quote={max_delta_quote}`\n" + f"`sl_global={sl_global}`\n" + f"`tp_global={tp_global}`\n" + f"`hedge_position_timeout={hedge_position_timeout}`\n" + f"`maker_tp_multiplier={maker_tp_multiplier}`\n" + f"`leverage={leverage}`\n" + f"`position_mode={escape_markdown_v2(position_mode)}`\n" + f"`macd_fast={macd_fast}`\n" + f"`macd_slow={macd_slow}`\n" + f"`macd_signal={macd_signal}`\n" + f"`natr_length={natr_length}`\n" + f"`interval={escape_markdown_v2(interval)}`\n\n" + r"_Edit: `field=value`_" + ) + + try: + await msg.edit_text( + config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + try: + await msg.delete() + except Exception: + pass + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["dnmm_wizard_message_id"] = new_msg.message_id + context.user_data["dnmm_wizard_chat_id"] = chat_id + + +async def handle_dnmm_save(update, context) -> None: + """Save Delta Neutral MM config - pulisce i campi non supportati""" + query = update.callback_query + config = get_controller_config(context) + + # Campi supportati dal controller + supported_fields = [ + "id", + "controller_name", + "controller_type", + "connector_pair_maker", + "connector_pair_hedge", + "candles_connector", + "candles_trading_pair", + "interval", + "macd_fast", + "macd_slow", + "macd_signal", + "natr_length", + "buy_spreads", + "sell_spreads", + "order_amount_quote", + "order_refresh_time", + "hedge_threshold_quote", + "max_delta_quote", + "leverage", + "position_mode", + "sl_global", + "tp_global", + "hedge_position_timeout", + "maker_tp_multiplier", + ] + + if "controller_type" not in config: + config["controller_type"] = "generic" + + keys_to_remove = [k for k in list(config.keys()) if k not in supported_fields] + for key in keys_to_remove: + config.pop(key, None) + + # Assicurati che connector_pair_maker e connector_pair_hedge abbiano la struttura corretta + for cp_key in ["connector_pair_maker", "connector_pair_hedge"]: + if cp_key in config and isinstance(config[cp_key], dict): + cp = config[cp_key] + config[cp_key] = { + "connector_name": cp.get("connector_name", ""), + "trading_pair": cp.get("trading_pair", "") + } + + config_id = config.get("id", "") + chat_id = query.message.chat_id + + try: + await query.message.delete() + except Exception: + pass + + status_msg = await context.bot.send_message( + chat_id=chat_id, + text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + await client.controllers.create_or_update_controller_config(config_id, config) + + for key in ["dnmm_wizard_step", "dnmm_wizard_message_id", "dnmm_wizard_chat_id", + "dnmm_price_maker", "dnmm_price_hedge"]: + context.user_data.pop(key, None) + context.user_data.pop("bots_state", None) + + keyboard = [ + [InlineKeyboardButton("Create Another", callback_data="bots:new_delta_neutral_mm")], + [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + "*Config Saved\\!*\n\n" + "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"DNMM save error: {e}", exc_info=True) + keyboard = [ + [InlineKeyboardButton("Try Again", callback_data="bots:dnmm_save")], + [InlineKeyboardButton("Back", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + format_error_message(f"Failed to save: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +# Back handlers +async def handle_dnmm_back_to_maker_connector(update, context) -> None: + context.user_data["dnmm_wizard_step"] = "maker_connector" + await _dnmm_show_connector_step(update, context, role="maker") + + +async def handle_dnmm_back_to_hedge_connector(update, context) -> None: + context.user_data["dnmm_wizard_step"] = "hedge_connector" + await _dnmm_show_connector_step(update, context, role="hedge") + + +async def handle_dnmm_back_to_maker_pair(update, context) -> None: + context.user_data["dnmm_wizard_step"] = "maker_pair" + await _dnmm_show_pair_step(update, context, role="maker") + + +async def handle_dnmm_back_to_hedge_pair(update, context) -> None: + context.user_data["dnmm_wizard_step"] = "hedge_pair" + await _dnmm_show_pair_step(update, context, role="hedge") + + +async def handle_dnmm_back_to_amount(update, context) -> None: + context.user_data["dnmm_wizard_step"] = "total_amount_quote" + context.user_data.pop("dnmm_price_maker", None) + context.user_data.pop("dnmm_price_hedge", None) + await _dnmm_show_amount_step(update, context) + + +async def _dnmm_show_amount_step(update, context, target_message_id: int = None) -> None: + """DNMM Step 5: Enter Total Amount (versione per input testuale)""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + + use_message_id = target_message_id + if not query and not use_message_id: + logger.error("_dnmm_show_amount_step called without callback_query and without target_message_id") + return + + if not query and use_message_id: + wizard_chat_id = context.user_data.get("dnmm_wizard_chat_id", chat_id) + + maker = config.get("connector_pair_maker", {}) + hedge = config.get("connector_pair_hedge", {}) + c1 = maker.get("connector_name", "") + p1 = maker.get("trading_pair", "") + c2 = hedge.get("connector_name", "") + p2 = hedge.get("trading_pair", "") + + context.user_data["bots_state"] = "dnmm_wizard_input" + context.user_data["dnmm_wizard_step"] = "total_amount_quote" + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:dnmm_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:dnmm_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:dnmm_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:dnmm_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:dnmm_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:dnmm_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:dnmm_back_to_hedge_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + message_text = ( + r"*⚑ Delta Neutral MM \- Step 5/6*" + "\n\n" + f"🏭 `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"πŸ›‘οΈ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n" + r"πŸ’° *Total Amount \(split between legs\)*" + "\n" + r"_Select or type an amount:_" + ) + + if query and query.message: + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + 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), + ) + elif use_message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"Error editing message in _dnmm_show_amount_step: {e}") + new_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["dnmm_wizard_message_id"] = new_msg.message_id + + +async def process_dnmm_wizard_input(update, context, user_input: str) -> None: + """Process text input during Delta Neutral MM wizard""" + step = context.user_data.get("dnmm_wizard_step") + chat_id = update.effective_chat.id + config = get_controller_config(context) + message_id = context.user_data.get("dnmm_wizard_message_id") + wizard_chat_id = context.user_data.get("dnmm_wizard_chat_id", chat_id) + + try: + try: + await update.message.delete() + except Exception: + pass + + if step == "maker_pair": + pair = user_input.upper().strip().replace("/", "-").replace("_", "-") + connector = config.get("connector_pair_maker", {}).get("connector_name", "") + + if "-" not in pair: + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data="bots:dnmm_back_to_maker_connector")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + ] + error_text = ( + r"*⚑ Delta Neutral MM \- Step 2/6*" + "\n\n" + f"🏭 `{escape_markdown_v2(connector)}`\n\n" + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. SOL\-USDT\)*" + "\n\n" + r"Type the pair again:" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = await validate_trading_pair( + context.user_data, client, connector, pair + ) + + if not is_valid: + keyboard = [] + for sugg in suggestions[:4]: + keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:dnmm_maker_pair:{sugg}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:dnmm_back_to_maker_connector")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + error_text = ( + r"*⚑ Delta Neutral MM \- Step 2/6*" + "\n\n" + f"🏭 `{escape_markdown_v2(connector)}`\n\n" + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n" + r"*Did you mean?*" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + if correct_pair: + pair = correct_pair + + if "connector_pair_maker" not in config: + config["connector_pair_maker"] = {} + config["connector_pair_maker"]["trading_pair"] = pair + set_controller_config(context, config) + + # ========== VAI AL HEDGE CONNECTOR (STEP 3) - RICOSTRUISCI IL MESSAGGIO ========== + # VAI AL HEDGE CONNECTOR (STEP 3) + context.user_data["dnmm_wizard_step"] = "hedge_connector" + await _dnmm_show_connector_step(update, context, role="hedge", target_message_id=message_id) + return + + elif step == "hedge_pair": + pair = user_input.upper().strip().replace("/", "-").replace("_", "-") + connector = config.get("connector_pair_hedge", {}).get("connector_name", "") + + if "-" not in pair: + maker = config.get("connector_pair_maker", {}) + c1 = maker.get("connector_name", "") + p1 = maker.get("trading_pair", "") + + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data="bots:dnmm_back_to_hedge_connector")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + ] + error_text = ( + r"*⚑ Delta Neutral MM \- Step 4/6*" + "\n\n" + f"🏭 `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"πŸ›‘οΈ `{escape_markdown_v2(connector)}`\n\n" + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. SOL\-USDT\)*" + "\n\n" + r"Type the pair again:" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = await validate_trading_pair( + context.user_data, client, connector, pair + ) + + if not is_valid: + maker = config.get("connector_pair_maker", {}) + c1 = maker.get("connector_name", "") + p1 = maker.get("trading_pair", "") + + keyboard = [] + for sugg in suggestions[:4]: + keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:dnmm_hedge_pair:{sugg}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:dnmm_back_to_hedge_connector")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + error_text = ( + r"*⚑ Delta Neutral MM \- Step 4/6*" + "\n\n" + f"🏭 `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n" + f"πŸ›‘οΈ `{escape_markdown_v2(connector)}`\n\n" + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n" + r"*Did you mean?*" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + if correct_pair: + pair = correct_pair + + if "connector_pair_hedge" not in config: + config["connector_pair_hedge"] = {} + config["connector_pair_hedge"]["trading_pair"] = pair + set_controller_config(context, config) + + # ========== VAI ALL'AMOUNT (STEP 5) - RICOSTRUISCI IL MESSAGGIO ========== + # VAI ALL'AMOUNT (STEP 5) + context.user_data["dnmm_wizard_step"] = "total_amount_quote" + await _dnmm_show_amount_step(update, context, target_message_id=message_id) + return + + elif step == "total_amount_quote": + try: + amount = float(user_input.strip().replace("$", "").replace(",", "")) + config["total_amount_quote"] = amount + set_controller_config(context, config) + context.user_data["dnmm_wizard_step"] = "final" + + pair = config.get("connector_pair_maker", {}).get("trading_pair", "") + if message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=r"*⚑ Delta Neutral MM \- New Config*" + "\n\n" + f"⏳ Fetching configuration for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + except Exception: + pass + else: + tmp_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=r"*⚑ Delta Neutral MM \- New Config*" + "\n\n" + f"⏳ Fetching configuration for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + context.user_data["dnmm_wizard_message_id"] = tmp_msg.message_id + + await _dnmm_show_final_step(update, context) + + except ValueError: + keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:dnmm_back_to_amount")]] + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=r"*⚑ Delta Neutral MM \- Step 5*" + "\n\n" + r"❌ *Invalid amount*" + "\n\n" + r"Enter a positive number \(e\.g\. 500\):", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + elif step == "final": + if "=" in user_input: + for line in user_input.strip().split("\n"): + if "=" not in line: + continue + field, value = line.split("=", 1) + field = field.strip().lower() + value = value.strip() + try: + if field in ("total_amount_quote", "order_amount_quote", "hedge_threshold_quote", "max_delta_quote", + "sl_global", "tp_global", "maker_tp_multiplier"): + config[field] = float(value) + elif field in ("order_refresh_time", "hedge_position_timeout", "leverage", + "macd_fast", "macd_slow", "macd_signal", "natr_length"): + config[field] = int(float(value)) + elif field in ("buy_spreads", "sell_spreads"): + config[field] = value + elif field == "interval": + config["interval"] = value + elif field == "position_mode": + config["position_mode"] = value.upper() + else: + config[field] = value + except Exception: + pass + set_controller_config(context, config) + await _dnmm_show_final_step(update, context) + + except Exception as e: + logger.error(f"DNMM wizard input error: {e}", exc_info=True) + +# ============================================ +# BOLLINGER GRID WIZARD (bollingrid) +# ============================================ +# Steps: connector β†’ pair β†’ leverage (perp) β†’ position_mode (perp) β†’ amount β†’ final+chart +# Prefisso handler: bg_ + +async def show_new_bollingrid_form(update, context) -> None: + """Start the Bollinger Grid wizard - Step 1: Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + # Clear cached data + for key in ["bg_current_price", "bg_candles", "bg_candles_interval", "bg_chart_interval", "bg_bbp"]: + context.user_data.pop(key, None) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + 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}") + + init_new_controller_config(context, "bollingrid") + context.user_data["bots_state"] = "bg_wizard" + context.user_data["bg_wizard_step"] = "connector_name" + context.user_data["bg_wizard_message_id"] = query.message.message_id + context.user_data["bg_wizard_chat_id"] = query.message.chat_id + + await _bg_show_connector_step(update, context) + + +async def _bg_show_connector_step(update, context, target_message_id: int = None) -> None: + """BG Step 1: Select Connector""" + query = update.callback_query + use_message_id = target_message_id + chat_id = update.effective_chat.id + + if not query and not use_message_id: + logger.error("_bg_show_connector_step called without callback_query and without target_message_id") + return + + try: + client, server_name = await get_bots_client(chat_id, context.user_data) + cex_connectors = await get_available_cex_connectors( + context.user_data, client, server_name=server_name + ) + + if not cex_connectors: + keyboard = [ + [InlineKeyboardButton("πŸ”‘ Configure API Keys", callback_data="config_api_keys")], + [InlineKeyboardButton("Β« Back", callback_data="bots:main_menu")], + ] + error_text = r"*πŸ“Š Bollinger Grid \- New Config*" + "\n\n" + r"⚠️ No CEX connectors available\." + if query and query.message: + await query.message.edit_text(error_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard)) + return + + keyboard = [] + row = [] + for connector in cex_connectors: + row.append(InlineKeyboardButton(f"🏦 {connector}", callback_data=f"bots:bg_connector:{connector}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + message_text = ( + r"*πŸ“Š Bollinger Grid*" + "\n\n" + r"Grid trading strategy activated by Bollinger Band Percent \(BBP\)\. " + r"Creates a grid when BBP indicates oversold \(\< long threshold\) or overbought \(\> short threshold\)\." + "\n\n" + r"─────────────────────────" + "\n\n" + r"*Step 1: Select Exchange*" + ) + + if query and query.message: + await query.message.edit_text(message_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard)) + elif use_message_id: + wizard_chat_id = context.user_data.get("bg_wizard_chat_id", chat_id) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=use_message_id, + text=message_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"BG connector step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + error_text = format_error_message(f"Error: {str(e)}") + if query and query.message: + await query.message.edit_text(error_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard)) + + +async def handle_bg_wizard_connector(update, context, connector: str) -> None: + config = get_controller_config(context) + config["connector_name"] = connector + config["candles_connector"] = connector + set_controller_config(context, config) + context.user_data["bg_wizard_step"] = "trading_pair" + await _bg_show_pair_step(update, context) + logger.info(f"BG DEBUG: handle_bg_wizard_connector - connector = '{connector}'") + + +async def _bg_show_pair_step(update, context, target_message_id: int = None) -> None: + """BG Step 2: Select Trading Pair""" + query = update.callback_query + use_message_id = target_message_id + config = get_controller_config(context) + connector = config.get("connector_name", "") + + if not query and not use_message_id: + logger.error("_bg_show_pair_step called without callback_query and without target_message_id") + return + + context.user_data["bots_state"] = "bg_wizard_input" + context.user_data["bg_wizard_step"] = "trading_pair" + + # Recent pairs from existing configs + 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:bg_pair:{pair}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:bg_back_to_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 6 if is_perp else 4 + current_step = 2 + + message_text = ( + rf"*πŸ“Š Bollinger Grid \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n" + r"πŸ”— *Trading Pair*" + "\n\n" + r"Select a recent pair or type a new one:" + ) + + if query and query.message: + await query.message.edit_text(message_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard)) + elif use_message_id: + wizard_chat_id = context.user_data.get("bg_wizard_chat_id", update.effective_chat.id) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=use_message_id, + text=message_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_bg_wizard_pair(update, context, pair: str) -> None: + config = get_controller_config(context) + config["trading_pair"] = pair + config["candles_trading_pair"] = pair + set_controller_config(context, config) + + connector = config.get("connector_name", "") + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + + if is_perp: + context.user_data["bg_wizard_step"] = "leverage" + await _bg_show_leverage_step(update, context) + else: + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + set_controller_config(context, config) + context.user_data["bg_wizard_step"] = "total_amount_quote" + await _bg_show_amount_step(update, context) + + +async def _bg_show_leverage_step(update, context, target_message_id: int = None) -> None: + """BG Step 3 (perp only): Select Leverage""" + query = update.callback_query + use_message_id = target_message_id + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + + if not query and not use_message_id: + logger.error("_bg_show_leverage_step called without callback_query and without target_message_id") + return + + keyboard = [ + [ + InlineKeyboardButton("1x", callback_data="bots:bg_leverage:1"), + InlineKeyboardButton("5x", callback_data="bots:bg_leverage:5"), + InlineKeyboardButton("10x", callback_data="bots:bg_leverage:10"), + ], + [ + InlineKeyboardButton("20x", callback_data="bots:bg_leverage:20"), + InlineKeyboardButton("50x", callback_data="bots:bg_leverage:50"), + InlineKeyboardButton("75x", callback_data="bots:bg_leverage:75"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:bg_back_to_pair"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + message_text = ( + r"*πŸ“Š Bollinger Grid \- Step 3/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + "\n\n" + r"⚑ *Select Leverage*" + "\n" + r"_Or type a value \(e\.g\. 20\)_" + ) + + if query and query.message: + await query.message.edit_text(message_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard)) + elif use_message_id: + wizard_chat_id = context.user_data.get("bg_wizard_chat_id", update.effective_chat.id) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=use_message_id, + text=message_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_bg_wizard_leverage(update, context, leverage: int) -> None: + config = get_controller_config(context) + config["leverage"] = leverage + set_controller_config(context, config) + context.user_data["bg_wizard_step"] = "position_mode" + await _bg_show_position_mode_step(update, context) + + +async def _bg_show_position_mode_step(update, context, target_message_id: int = None) -> None: + """BG Step 4 (perp only): Position Mode""" + query = update.callback_query + use_message_id = target_message_id + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + leverage = config.get("leverage", 1) + + if not query and not use_message_id: + logger.error("_bg_show_position_mode_step called without callback_query and without target_message_id") + return + + keyboard = [ + [ + InlineKeyboardButton("πŸ”€ HEDGE", callback_data="bots:bg_position_mode:HEDGE"), + InlineKeyboardButton("➑️ ONEWAY", callback_data="bots:bg_position_mode:ONEWAY"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:bg_back_to_leverage"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + message_text = ( + f"*πŸ“Š Bollinger Grid \\- Step 4/6*\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}` \\| ⚑ `{leverage}x`\n\n" + r"πŸ“ *Position Mode*\n\n" + r"β€’ *HEDGE*: Can hold both long and short positions simultaneously\n" + r"β€’ *ONEWAY*: Can only hold positions in one direction" + ) + + if query and query.message: + await query.message.edit_text(message_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard)) + elif use_message_id: + wizard_chat_id = context.user_data.get("bg_wizard_chat_id", update.effective_chat.id) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=use_message_id, + text=message_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_bg_position_mode(update, context, mode: str) -> None: + config = get_controller_config(context) + config["position_mode"] = mode + set_controller_config(context, config) + context.user_data["bg_wizard_step"] = "total_amount_quote" + await _bg_show_amount_step(update, context) + +async def _bg_show_amount_step(update, context, target_message_id: int = None) -> None: + """BG Step: Enter Total Amount""" + query = update.callback_query + use_message_id = target_message_id + chat_id = update.effective_chat.id + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + leverage = config.get("leverage", 1) + pos_mode = config.get("position_mode", "HEDGE") + + if not query and not use_message_id: + logger.error("_bg_show_amount_step called without callback_query and without target_message_id") + return + + context.user_data["bots_state"] = "bg_wizard_input" + context.user_data["bg_wizard_step"] = "total_amount_quote" + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 6 if is_perp else 4 + current_step = 5 if is_perp else 3 + + back_callback = "bots:bg_back_to_position_mode" if is_perp else "bots:bg_back_to_pair" + + # πŸ”§ FIX: Normalizza il nome del connector come in DMan + balance_text = "" + try: + client, _ = await get_bots_client(chat_id, context.user_data) + balances = await get_cex_balances( + context.user_data, client, "master_account", ttl=30 + ) + + # Flexible matching come in Grid Strike + 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", "") + if bal_lower == connector_lower or bal_base == connector_base: + connector_balances = bal_list + break + + if connector_balances: + relevant_balances = [] + quote = pair.split("-")[1] if "-" in pair else "USDT" + for bal in connector_balances: + token = bal.get("token", bal.get("asset", "")) + available = bal.get("units", bal.get("available_balance", bal.get("free", 0))) + if token and token.upper() == quote.upper(): + try: + available_float = float(available) + if available_float > 0: + balance_text = f"\n\nπŸ’° Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`" + break + except (ValueError, TypeError): + continue + except Exception as e: + logger.warning(f"Could not fetch balances for BollinGrid amount step: {e}") + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:bg_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:bg_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:bg_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:bg_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:bg_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:bg_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data=back_callback), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ”— `{escape_markdown_v2(pair)}`" + if is_perp: + header_info += f" \\| ⚑ `{leverage}x` \\| 🎯 `{escape_markdown_v2(pos_mode)}`" + + message_text = ( + rf"*πŸ“Š Bollinger Grid \- Step {current_step}/{total_steps}*" + "\n\n" + + header_info + balance_text + "\n\n" + r"πŸ’° *Total Amount \(USDT\)*" + "\n" + r"_Select or type an amount:_" + ) + + if query and query.message: + await query.message.edit_text(message_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard)) + elif use_message_id: + wizard_chat_id = context.user_data.get("bg_wizard_chat_id", chat_id) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=use_message_id, + text=message_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_bg_wizard_amount(update, context, amount: float) -> None: + query = update.callback_query + config = get_controller_config(context) + config["total_amount_quote"] = amount + set_controller_config(context, config) + + pair = config.get("trading_pair", "") + await query.message.edit_text( + r"*πŸ“Š Bollinger Grid \- New Config*" + "\n\n" + f"⏳ *Loading market data for* `{escape_markdown_v2(pair)}`\\.\\.\\. " + "\n\n" + r"_Fetching price and calculating BBP\.\.\._", + parse_mode="MarkdownV2", + ) + + context.user_data["bg_wizard_step"] = "final" + await _bg_show_final_step(update, context) + + +async def _bg_show_final_step(update, context, interval: str = None) -> None: + """BG Final Step: Chart + Config Summary + BBP Signal""" + import asyncio + from .controllers.bollingrid import BollinGridController + from .controllers.bollingrid import generate_id as bg_generate_id + query = update.callback_query + msg = query.message if query else update.message + chat_id = update.effective_chat.id + config = get_controller_config(context) + connector = config.get("connector_name", "") + pair = config.get("trading_pair", "") + total_amount = config.get("total_amount_quote", 1000) + leverage = config.get("leverage", 1) + position_mode = config.get("position_mode", "HEDGE") + bb_length = config.get("bb_length", 100) + bb_std = config.get("bb_std", 2.0) + bb_long = config.get("bb_long_threshold", 0.0) + bb_short = config.get("bb_short_threshold", 1.0) + start_coeff = config.get("grid_start_price_coefficient", 0.25) + end_coeff = config.get("grid_end_price_coefficient", 0.75) + limit_coeff = config.get("grid_limit_price_coefficient", 0.35) + min_spread = config.get("min_spread_between_orders", 0.005) + order_freq = config.get("order_frequency", 2) + max_orders_batch = config.get("max_orders_per_batch", 1) + min_order_amt = config.get("min_order_amount_quote", 6) + max_open_orders = config.get("max_open_orders", 5) + stop_loss = config.get("stop_loss", 0.05) + take_profit = config.get("take_profit", 0.03) + + if interval is None: + interval = context.user_data.get("bg_chart_interval", config.get("interval", "5m")) + context.user_data["bg_chart_interval"] = interval + config["interval"] = interval + set_controller_config(context, config) + + current_price = context.user_data.get("bg_current_price") + candles = context.user_data.get("bg_candles") + + try: + cached_interval = context.user_data.get("bg_candles_interval", interval) + if not current_price or interval != cached_interval: + try: + await msg.edit_text( + r"*πŸ“Š Bollinger Grid \- New Config*" + "\n\n" + f"⏳ Fetching market data for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + except Exception: + pass + + client, _ = await get_bots_client(chat_id, context.user_data) + + candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "") + if not candles_connector or len(candles_connector) < 3: + candles_connector = "kucoin" + + current_price = await fetch_current_price(client, connector, pair) + + if current_price: + context.user_data["bg_current_price"] = current_price + candles = await fetch_candles( + client, candles_connector, pair, interval=interval, max_records=420 + ) + context.user_data["bg_candles"] = candles + context.user_data["bg_candles_interval"] = interval + + if not current_price: + keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]] + await msg.edit_text( + r"*❌ Error*" + "\n\n" + f"Could not fetch price for `{escape_markdown_v2(pair)}`\\.", + parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + candles_list = candles.get("data", []) if isinstance(candles, dict) else (candles or []) + + if candles_list: + last_close = candles_list[-1].get("close") or candles_list[-1].get("c") + if last_close: + current_price = float(last_close) + context.user_data["bg_current_price"] = current_price + + # Calculate grid prices based on coefficients + start_price = current_price * (1 - start_coeff) + end_price = current_price * (1 + end_coeff) + limit_price = current_price * (1 - limit_coeff) + + config["start_price"] = start_price + config["end_price"] = end_price + config["limit_price"] = limit_price + + + # Calculate BBP and signal + bbp = None + bbp_signal = "" + bb_width_pct = 0.02 # fallback 2% + + if candles_list and len(candles_list) >= bb_length: + closes = [float(c.get("close") or c.get("c", 0)) for c in candles_list[-bb_length:]] + if closes: + mean = sum(closes) / len(closes) + std = (sum((x - mean) ** 2 for x in closes) / len(closes)) ** 0.5 + upper = mean + bb_std * std + lower = mean - bb_std * std + + # Calcola BB Width (larghezza normalizzata delle Bollinger Bands) + if upper > lower and current_price > 0: + bb_width_pct = (upper - lower) / current_price + logger.info(f"BG Chart: BB Width = {bb_width_pct:.4f} ({bb_width_pct*100:.2f}%)") + + # Calcola BBP per il segnale + if upper != lower: + bbp = (current_price - lower) / (upper - lower) + if bbp < bb_long: + bbp_signal = r" 🟒 LONG READY (BBP < long threshold)" + elif bbp > bb_short: + bbp_signal = r" πŸ”΄ SHORT READY (BBP > short threshold)" + else: + bbp_signal = r" βšͺ No signal (BBP in neutral zone)" + + # Calcola prezzi della griglia come nel controller BollinGridController + # LONG: start = price * (1 - bb_width * start_coeff) + # end = price * (1 + bb_width * end_coeff) + # limit = price * (1 - bb_width * limit_coeff) + # SHORT: start = price * (1 - bb_width * end_coeff) (invertito) + # end = price * (1 + bb_width * start_coeff) + # limit = price * (1 + bb_width * limit_coeff) + + side_value = config.get("side", 1) # 1=LONG, 2=SHORT + + if side_value == 2: # SHORT + start_price = current_price * (1 - bb_width_pct * end_coeff) + end_price = current_price * (1 + bb_width_pct * start_coeff) + limit_price = current_price * (1 + bb_width_pct * limit_coeff) + else: # LONG (default) + start_price = current_price * (1 - bb_width_pct * start_coeff) + end_price = current_price * (1 + bb_width_pct * end_coeff) + limit_price = current_price * (1 - bb_width_pct * limit_coeff) + + # Arrotonda per una migliore visualizzazione + start_price = round(start_price, 8) + end_price = round(end_price, 8) + limit_price = round(limit_price, 8) + + logger.info(f"BG Chart: Grid prices - start={start_price}, end={end_price}, limit={limit_price}") + + config["start_price"] = start_price + config["end_price"] = end_price + config["limit_price"] = limit_price + + # Generate config ID if not set + if not config.get("id"): + existing_configs = context.user_data.get("controller_configs_list", []) + config["id"] = bg_generate_id(config, existing_configs) + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + final_step = 6 if is_perp else 4 + + context.user_data["bots_state"] = "bg_wizard_input" + context.user_data["bg_wizard_step"] = "final" + + interval_options = ["1m", "5m", "15m", "1h", "8h"] + interval_row = [ + InlineKeyboardButton( + f"βœ“ {opt}" if opt == interval else opt, + callback_data=f"bots:bg_interval:{opt}" + ) + for opt in interval_options + ] + + keyboard = [ + interval_row, + [InlineKeyboardButton("πŸ’Ύ Save Config", callback_data="bots:bg_save")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:bg_back_to_amount"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + bbp_info = f"BBP: `{bbp:.3f}`" if bbp else "BBP: N/A" + grid_range = f"Grid: `{start_price:.6g}` β†’ `{end_price:.6g}`" + + # Escapa i valori per MarkdownV2 + escaped_title = escape_markdown_v2(f"πŸ“Š Bollinger Grid - Step {final_step}/{final_step} (Final)") + escaped_pair = escape_markdown_v2(pair) + escaped_connector = escape_markdown_v2(connector) + escaped_position_mode = escape_markdown_v2(position_mode) + escaped_interval = escape_markdown_v2(interval) + + # Linea del prezzo con BBP e interval + price_line = f"Price: {current_price:,.6g} | {bbp_info} | Interval: {interval}" + escaped_price_line = escape_markdown_v2(price_line) + + # Grid range (giΓ  escapato da escape_markdown_v2) + escaped_grid_range = escape_markdown_v2(grid_range) + + config_text = ( + f"*{escaped_title}*\n\n" + f"*{escaped_pair}*\n" + f"{escaped_price_line}\n" + f"{escaped_grid_range}\n\n" + f"`connector_name={escaped_connector}`\n" + f"`trading_pair={escaped_pair}`\n" + f"`total_amount_quote={total_amount:.0f}`\n" + f"`leverage={leverage}`\n" + f"`position_mode={escaped_position_mode}`\n" + f"`interval={escaped_interval}`\n" + f"`bb_length={bb_length}`\n" + f"`bb_std={bb_std}`\n" + f"`bb_long_threshold={bb_long}`\n" + f"`bb_short_threshold={bb_short}`\n" + f"`grid_start_price_coefficient={start_coeff}`\n" + f"`grid_end_price_coefficient={end_coeff}`\n" + f"`grid_limit_price_coefficient={limit_coeff}`\n" + f"`min_spread_between_orders={min_spread}`\n" + f"`order_frequency={order_freq}`\n" + f"`max_orders_per_batch={max_orders_batch}`\n" + f"`min_order_amount_quote={min_order_amt}`\n" + f"`max_open_orders={max_open_orders}`\n" + f"`stop_loss={stop_loss}`\n" + f"`take_profit={take_profit}`\n\n" + r"_Edit: `field=value`_" + ) + # Generate chart with Bollinger Bands and grid lines + if candles_list: + chart_bytes = BollinGridController.generate_chart(config, candles_list, current_price) + + try: + await msg.delete() + except Exception: + pass + + new_msg = await context.bot.send_photo( + chat_id=chat_id, + photo=chart_bytes, + caption=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["bg_wizard_message_id"] = new_msg.message_id + context.user_data["bg_wizard_chat_id"] = chat_id + else: + try: + await msg.edit_text(text=config_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard)) + except Exception: + new_msg = await context.bot.send_message( + chat_id=chat_id, text=config_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["bg_wizard_message_id"] = new_msg.message_id + + except Exception as e: + logger.error(f"BG final step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + try: + await msg.edit_text( + format_error_message(f"Error: {str(e)}"), + parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + pass + + +# Back handlers per Bollinger Grid +async def handle_bg_save(update, context) -> None: + """Save Bollinger Grid configuration""" + query = update.callback_query + config = get_controller_config(context) + + # Clean up temporary fields + config.pop("candles_config", None) + config.pop("manual_kill_switch", None) + config.pop("start_price", None) + config.pop("end_price", None) + config.pop("limit_price", None) + + config_id = config.get("id", "") + chat_id = query.message.chat_id + + try: + await query.message.delete() + except Exception: + pass + + status_msg = await context.bot.send_message( + chat_id=chat_id, + text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + await client.controllers.create_or_update_controller_config(config_id, config) + + for key in ["bg_wizard_step", "bg_wizard_message_id", "bg_wizard_chat_id", + "bg_current_price", "bg_candles", "bg_candles_interval", "bg_chart_interval", "bg_bbp"]: + context.user_data.pop(key, None) + context.user_data.pop("bots_state", None) + + keyboard = [ + [InlineKeyboardButton("Create Another", callback_data="bots:new_bollingrid")], + [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + "*Config Saved\\!*\n\n" + "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"BG save error: {e}", exc_info=True) + keyboard = [ + [InlineKeyboardButton("Try Again", callback_data="bots:bg_save")], + [InlineKeyboardButton("Back", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + format_error_message(f"Failed to save: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_bg_interval_change(update, context, interval: str) -> None: + """Change chart interval""" + context.user_data["bg_candles"] = None + context.user_data["bg_candles_interval"] = None + await _bg_show_final_step(update, context, interval=interval) + + +async def handle_bg_back_to_connector(update, context) -> None: + context.user_data["bg_wizard_step"] = "connector_name" + await _bg_show_connector_step(update, context) + + +async def handle_bg_back_to_pair(update, context) -> None: + context.user_data["bg_wizard_step"] = "trading_pair" + await _bg_show_pair_step(update, context) + + +async def handle_bg_back_to_leverage(update, context) -> None: + config = get_controller_config(context) + connector = config.get("connector_name", "") + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + if is_perp: + context.user_data["bg_wizard_step"] = "leverage" + await _bg_show_leverage_step(update, context) + else: + await handle_bg_back_to_pair(update, context) + + +async def handle_bg_back_to_position_mode(update, context) -> None: + context.user_data["bg_wizard_step"] = "position_mode" + await _bg_show_position_mode_step(update, context) + + +async def handle_bg_back_to_amount(update, context) -> None: + context.user_data["bg_wizard_step"] = "total_amount_quote" + context.user_data.pop("bg_current_price", None) + context.user_data.pop("bg_candles", None) + await _bg_show_amount_step(update, context) + + +async def handle_bg_pair_select(update, context, pair: str) -> None: + """Handle selection of a suggested trading pair in Bollinger Grid wizard""" + await handle_bg_wizard_pair(update, context, pair) + + +async def process_bg_wizard_input(update, context, user_input: str) -> None: + """Process text input during Bollinger Grid wizard""" + step = context.user_data.get("bg_wizard_step") + chat_id = update.effective_chat.id + config = get_controller_config(context) + message_id = context.user_data.get("bg_wizard_message_id") + wizard_chat_id = context.user_data.get("bg_wizard_chat_id", chat_id) + + try: + try: + await update.message.delete() + except Exception: + pass + + if step == "trading_pair": + pair = user_input.upper().strip().replace("/", "-").replace("_", "-") + connector = config.get("connector_name", "") + + if "-" not in pair: + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data="bots:bg_back_to_connector")], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + ] + error_text = ( + r"*πŸ“Š Bollinger Grid \- Step 2*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}`\n\n" + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n" + r"Type the pair again:" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = await validate_trading_pair( + context.user_data, client, connector, pair + ) + + if not is_valid: + keyboard = [] + for sugg in suggestions[:4]: + keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:bg_pair_select:{sugg}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:bg_back_to_connector")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + error_text = ( + r"*πŸ“Š Bollinger Grid \- Step 2*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}`\n\n" + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n" + r"*Did you mean?*" + ) + if message_id: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=error_text, parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + if correct_pair: + pair = correct_pair + + config["trading_pair"] = pair + config["candles_trading_pair"] = pair + for key in ["bg_current_price", "bg_candles", "bg_candles_interval"]: + context.user_data.pop(key, None) + set_controller_config(context, config) + + connector = config.get("connector_name", "") + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + + if is_perp: + context.user_data["bg_wizard_step"] = "leverage" + await _bg_show_leverage_step(update, context, target_message_id=message_id) + else: + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + set_controller_config(context, config) + context.user_data["bg_wizard_step"] = "total_amount_quote" + await _bg_show_amount_step(update, context, target_message_id=message_id) + return + + elif step == "leverage": + try: + val = int(float(user_input.strip().lower().replace("x", ""))) + config["leverage"] = val + set_controller_config(context, config) + context.user_data["bg_wizard_step"] = "position_mode" + await _bg_show_position_mode_step(update, context, target_message_id=message_id) + except ValueError: + keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:bg_back_to_leverage")]] + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=r"*πŸ“Š Bollinger Grid*" + "\n\n" + r"❌ *Invalid leverage*" + "\n\n" + r"Enter a positive number \(e\.g\. 20\):", + parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard), + ) + + elif step == "total_amount_quote": + try: + amount = float(user_input.strip().replace("$", "").replace(",", "")) + config["total_amount_quote"] = amount + set_controller_config(context, config) + context.user_data["bg_wizard_step"] = "final" + + pair = config.get("trading_pair", "") + if message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=r"*πŸ“Š Bollinger Grid*" + "\n\n" + f"⏳ Loading market data for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + except Exception: + pass + else: + tmp_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=r"*πŸ“Š Bollinger Grid*" + "\n\n" + f"⏳ Loading market data for `{escape_markdown_v2(pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + context.user_data["bg_wizard_message_id"] = tmp_msg.message_id + + await _bg_show_final_step(update, context) + + except ValueError: + keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:bg_back_to_amount")]] + await context.bot.edit_message_text( + chat_id=wizard_chat_id, message_id=message_id, + text=r"*πŸ“Š Bollinger Grid*" + "\n\n" + r"❌ *Invalid amount*" + "\n\n" + r"Enter a positive number \(e\.g\. 500\):", + parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard), + ) + + elif step == "final": + if "=" in user_input: + supported_fields = [ + "total_amount_quote", "leverage", "position_mode", "interval", + "bb_length", "bb_std", "bb_long_threshold", "bb_short_threshold", + "grid_start_price_coefficient", "grid_end_price_coefficient", + "grid_limit_price_coefficient", "min_spread_between_orders", + "order_frequency", "max_orders_per_batch", "min_order_amount_quote", + "max_open_orders", "stop_loss", "take_profit", + "trailing_stop_activation", "trailing_stop_delta" + ] + + for line in user_input.strip().split("\n"): + if "=" not in line: + continue + field, value = line.split("=", 1) + field = field.strip().lower() + value = value.strip() + + if field not in supported_fields: + continue + + try: + if field in ("total_amount_quote", "bb_std", "bb_long_threshold", "bb_short_threshold", + "grid_start_price_coefficient", "grid_end_price_coefficient", + "grid_limit_price_coefficient", "min_spread_between_orders", + "min_order_amount_quote", "stop_loss", "take_profit", + "trailing_stop_activation", "trailing_stop_delta"): + config[field] = float(value) + elif field in ("leverage", "bb_length", "order_frequency", "max_orders_per_batch", + "max_open_orders"): + config[field] = int(float(value)) + elif field == "interval": + config["interval"] = value + context.user_data.pop("bg_candles", None) + context.user_data["bg_chart_interval"] = value + elif field == "position_mode": + config["position_mode"] = value.upper() + else: + config[field] = value + except Exception: + pass + + set_controller_config(context, config) + await _bg_show_final_step(update, context) + + except Exception as e: + logger.error(f"BG wizard input error: {e}", exc_info=True) + + +# ============================================ +# QUANTUM GRID ALLOCATOR WIZARD (qga) +# ============================================ +# Steps: connector β†’ quote_asset β†’ portfolio_allocation β†’ grid_params β†’ amount β†’ final +# Prefisso handler: qga_ + +async def show_new_quantum_grid_allocator_form(update, context) -> None: + """Start the Quantum Grid Allocator wizard - Step 1: Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + # RESETTA LE ALLOCAZIONI + config = get_controller_config(context) + if config: + config["portfolio_allocation"] = {} + set_controller_config(context, config) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + 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}") + + init_new_controller_config(context, "quantum_grid_allocator") + + # Resetta anche qui per sicurezza + config = get_controller_config(context) + if config: + config["portfolio_allocation"] = {} + set_controller_config(context, config) + + context.user_data["bots_state"] = "qga_wizard" + context.user_data["qga_wizard_step"] = "connector_name" + context.user_data["qga_wizard_message_id"] = query.message.message_id + context.user_data["qga_wizard_chat_id"] = query.message.chat_id + + await _qga_show_connector_step(update, context) +async def _qga_show_connector_step(update, context) -> None: + """QGA Step 1: Select Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + try: + client, server_name = await get_bots_client(chat_id, context.user_data) + cex_connectors = await get_available_cex_connectors( + context.user_data, client, server_name=server_name + ) + + if not cex_connectors: + keyboard = [ + [InlineKeyboardButton("πŸ”‘ Configure API Keys", callback_data="config_api_keys")], + [InlineKeyboardButton("Β« Back", callback_data="bots:main_menu")], + ] + await query.message.edit_text( + r"*⚑ Quantum Grid Allocator \- New Config*" + "\n\n" + r"⚠️ No CEX connectors available\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + keyboard = [] + row = [] + for connector in cex_connectors: + row.append(InlineKeyboardButton( + f"🏦 {connector}", callback_data=f"bots:qga_connector:{connector}" + )) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + await query.message.edit_text( + r"*⚑ Quantum Grid Allocator*" + "\n\n" + r"Portfolio rebalancing strategy that automatically allocates capital " + r"across multiple assets based on deviation from target allocations\. " + r"Uses grid trading to rebalance when deviations exceed thresholds\." + "\n\n" + r"─────────────────────────" + "\n\n" + r"*Step 1: Select Exchange*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"QGA connector step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + await query.message.edit_text( + format_error_message(f"Error: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_qga_wizard_connector(update, context, connector: str) -> None: + config = get_controller_config(context) + config["connector_name"] = connector + set_controller_config(context, config) + context.user_data["qga_wizard_step"] = "quote_asset" + await _qga_show_quote_asset_step(update, context) + + +async def _qga_show_quote_asset_step(update, context) -> None: + """QGA Step 2: Select Quote Asset""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + + context.user_data["bots_state"] = "qga_wizard_input" + context.user_data["qga_wizard_step"] = "quote_asset" + + quote_options = ["USDT", "FDUSD", "USDC", "BUSD"] + + keyboard = [] + row = [] + for quote in quote_options: + row.append(InlineKeyboardButton(quote, callback_data=f"bots:qga_quote:{quote}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:qga_back_to_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + escaped_connector = escape_markdown_v2(connector) + escaped_header = escape_markdown_v2("⚑ Quantum Grid Allocator - Step 2/5") + + message_text = ( + f"*{escaped_header}*\n\n" + f"🏦 `{escaped_connector}`\n\n" + f"πŸ’° *Quote Asset*\n\n" + f"Select the quote currency for trading pairs:" + ) + + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["qga_wizard_message_id"] = query.message.message_id + except Exception: + try: + await query.message.delete() + except Exception: + pass + new_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["qga_wizard_message_id"] = new_msg.message_id + context.user_data["qga_wizard_chat_id"] = query.message.chat_id + +async def handle_qga_wizard_quote(update, context, quote: str) -> None: + config = get_controller_config(context) + config["quote_asset"] = quote + set_controller_config(context, config) + context.user_data["qga_wizard_step"] = "portfolio_allocation" + await _qga_show_portfolio_step(update, context) + +async def _qga_show_portfolio_step(update, context, target_message_id: int = None) -> None: + """QGA Step 3: Configure Portfolio Allocation (solo input manuale)""" + query = update.callback_query + use_message_id = target_message_id + config = get_controller_config(context) + connector = config.get("connector_name", "") + quote = config.get("quote_asset", "FDUSD") + + if not query and not use_message_id: + logger.error("_qga_show_portfolio_step called without callback_query and without target_message_id") + return + + context.user_data["bots_state"] = "qga_wizard_input" + context.user_data["qga_wizard_step"] = "portfolio_allocation" + + # Ottieni l'allocazione corrente + current_alloc = config.get("portfolio_allocation", {}) + if isinstance(current_alloc, str): + import json + try: + current_alloc = json.loads(current_alloc) + except: + current_alloc = {} + + total_pct = sum(current_alloc.values()) + remaining_pct = 1 - total_pct + + # Costruisci il testo dell'allocazione corrente + alloc_lines = [] + for asset, pct in current_alloc.items(): + pct_val = float(pct) if not isinstance(pct, float) else pct + alloc_lines.append(f" β€’ {asset}: {pct_val*100:.0f}%") + + # Mostra USDT solo se remaining > 0 (non mostrare 0%) + if remaining_pct > 0.01: # piΓΉ dello 0.5% + alloc_lines.append(f" β€’ {quote}: {remaining_pct*100:.0f}% (remaining)") + elif remaining_pct > 0: + alloc_lines.append(f" β€’ {quote}: {remaining_pct*100:.1f}% (remaining)") + # Se remaining_pct <= 0, non mostrare USDT + + current_text = "\n".join(alloc_lines) if alloc_lines else " β€’ None" + + # Escapa TUTTO il current_text + current_text_escaped = escape_markdown_v2(current_text) + + # Costruisci i bottoni + keyboard = [] + + if total_pct >= 0.99: # Se abbiamo raggiunto ~100% + keyboard.append([InlineKeyboardButton("βœ… Next", callback_data="bots:qga_next")]) + + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:qga_back_to_quote"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + escaped_connector = escape_markdown_v2(connector) + escaped_header = escape_markdown_v2("⚑ Quantum Grid Allocator - Step 3/5") + escaped_quote = escape_markdown_v2(quote) + + # Mostra remaining solo se > 0 + remaining_text = "" + if remaining_pct > 0: + escaped_remaining = escape_markdown_v2(f"{remaining_pct*100:.0f}%") + remaining_text = f"Remaining: {escaped_remaining}\n\n" + + message_text = ( + f"*{escaped_header}*\n\n" + f"🏦 `{escaped_connector}` \\| πŸ’° `{escaped_quote}`\n\n" + "*πŸ“Š Portfolio Allocation*\n\n" + f"Current allocation:\n{current_text_escaped}\n\n" + f"{remaining_text}" + "_Type: ASSET:PCT \\(e\\.g\\.\\, SOL:0\\.5 for 50%\\)_" + ) + + reply_markup = InlineKeyboardMarkup(keyboard) + + if query and query.message: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=reply_markup, + ) + context.user_data["qga_wizard_message_id"] = query.message.message_id + elif use_message_id: + wizard_chat_id = context.user_data.get("qga_wizard_chat_id", update.effective_chat.id) + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=reply_markup, + ) + except Exception: + try: + await context.bot.delete_message(chat_id=wizard_chat_id, message_id=use_message_id) + except Exception: + pass + new_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=reply_markup, + ) + context.user_data["qga_wizard_message_id"] = new_msg.message_id + context.user_data["qga_wizard_chat_id"] = wizard_chat_id + + +async def handle_qga_add_asset(update, context, asset: str, allocation: float) -> None: + """Add asset to portfolio allocation""" + query = update.callback_query + config = get_controller_config(context) + + portfolio = config.get("portfolio_allocation", {}) + if isinstance(portfolio, str): + portfolio = {} + + portfolio[asset] = allocation + config["portfolio_allocation"] = portfolio + set_controller_config(context, config) + + await query.answer(f"Added {asset} with {allocation*100:.0f}%") + await _qga_show_portfolio_step(update, context) + + +async def handle_qga_alloc_next(update, context) -> None: + """Go to next step after portfolio allocation""" + query = update.callback_query + config = get_controller_config(context) + + portfolio = config.get("portfolio_allocation", {}) + if not portfolio: + await query.answer("Please add at least one asset", show_alert=True) + return + + total = sum(portfolio.values()) + if total >= 1.0: + await query.answer(f"Total allocation {total*100:.0f}% must be less than 100%", show_alert=True) + return + + context.user_data["qga_wizard_step"] = "grid_params" + await _qga_show_grid_params_step(update, context) + +async def handle_qga_amount(update, context) -> None: + """Passa allo step dell'amount""" + query = update.callback_query + await query.answer() + + config = get_controller_config(context) + + # Verifica che l'allocazione sia completa + portfolio = config.get("portfolio_allocation", {}) + if isinstance(portfolio, str): + import json + try: + portfolio = json.loads(portfolio) + except: + portfolio = {} + + total_pct = sum(portfolio.values()) + if total_pct < 0.99: + remaining = 1.0 - total_pct + await query.answer(f"Allocation only {total_pct*100:.0f}%. Add {remaining*100:.0f}% more to continue.", show_alert=True) + return + + context.user_data["qga_wizard_step"] = "total_amount_quote" + await _qga_show_amount_step(update, context) + +async def _qga_show_grid_params_step(update, context) -> None: + """QGA Step 4: Configure Grid Parameters""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + quote = config.get("quote_asset", "FDUSD") + + context.user_data["bots_state"] = "qga_wizard_input" + context.user_data["qga_wizard_step"] = "grid_params" + + keyboard = [ + [ + InlineKeyboardButton("βœ… Next", callback_data="bots:qga_amount"), + InlineKeyboardButton("⬅️ Back", callback_data="bots:qga_back_to_portfolio"), + ], + [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")], + ] + + # Get current values + base_grid_pct = config.get("base_grid_value_pct", 0.08) * 100 + max_grid_pct = config.get("max_grid_value_pct", 0.15) * 100 + grid_range = config.get("grid_range", 0.002) * 100 + tp_sl_ratio = config.get("tp_sl_ratio", 0.8) + min_order = config.get("min_order_amount", 5) + max_open = config.get("max_open_orders", 2) + long_only = config.get("long_only_threshold", 0.2) * 100 + short_only = config.get("short_only_threshold", 0.2) * 100 + escaped_connector = escape_markdown_v2(connector) + escaped_quote = escape_markdown_v2(quote) + + message_text = ( + f"*{escape_markdown_v2('⚑ Quantum Grid Allocator - Step 4/5')}*\n\n" + f"🏦 `{escaped_connector}` \\| πŸ’° `{escaped_quote}`\n\n" + r"βš™οΈ *Grid Parameters*" + "\n\n" + f"`base_grid_value_pct={base_grid_pct:.0f}%`\n" + f"`max_grid_value_pct={max_grid_pct:.0f}%`\n" + f"`grid_range={grid_range:.2f}%`\n" + f"`tp_sl_ratio={tp_sl_ratio}`\n" + f"`long_only_threshold={long_only:.0f}%`\n" + f"`short_only_threshold={short_only:.0f}%`\n" + f"`min_order_amount={min_order}`\n" + f"`max_open_orders={max_open}`\n\n" + r"_Edit: `field=value` \(e\.g\. `base_grid_value_pct=0.1`\)_" + ) + try: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["qga_wizard_message_id"] = query.message.message_id + except Exception: + try: + await query.message.delete() + except Exception: + pass + new_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["qga_wizard_message_id"] = new_msg.message_id + context.user_data["qga_wizard_chat_id"] = query.message.chat_id + + +async def handle_qga_amount_step(update, context) -> None: + """Go to amount step""" + context.user_data["qga_wizard_step"] = "total_amount_quote" + await _qga_show_amount_step(update, context) + +async def _qga_show_amount_step(update, context, target_message_id: int = None) -> None: + """QGA Step 4: Enter Total Amount""" + query = update.callback_query + use_message_id = target_message_id + chat_id = update.effective_chat.id + config = get_controller_config(context) + connector = config.get("connector_name", "") + quote = config.get("quote_asset", "FDUSD") + + if not query and not use_message_id: + logger.error("_qga_show_amount_step called without callback_query and without target_message_id") + return + + context.user_data["bots_state"] = "qga_wizard_input" + context.user_data["qga_wizard_step"] = "total_amount_quote" + + # Fetch balance + balance_text = "" + try: + client, _ = await get_bots_client(chat_id, context.user_data) + balances = await get_cex_balances( + context.user_data, client, "master_account", ttl=30 + ) + + # Flexible matching come in Grid Strike + 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", "") + if bal_lower == connector_lower or bal_base == connector_base: + connector_balances = bal_list + break + + if connector_balances: + relevant_balances = [] + quote = pair.split("-")[1] if "-" in pair else "USDT" + for bal in connector_balances: + token = bal.get("token", bal.get("asset", "")) + available = bal.get("units", bal.get("available_balance", bal.get("free", 0))) + if token and token.upper() == quote.upper(): + try: + available_float = float(available) + if available_float > 0: + balance_text = f"\n\nπŸ’° Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`" + break + except (ValueError, TypeError): + continue + except Exception as e: + logger.warning(f"Could not fetch balances for Quantum Grid Allocator amount step: {e}") + + keyboard = [ + [ + InlineKeyboardButton("$1000", callback_data="bots:qga_amount:1000"), + InlineKeyboardButton("$5000", callback_data="bots:qga_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:qga_amount:10000"), + ], + [ + InlineKeyboardButton("$25000", callback_data="bots:qga_amount:25000"), + InlineKeyboardButton("$50000", callback_data="bots:qga_amount:50000"), + InlineKeyboardButton("$100000", callback_data="bots:qga_amount:100000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:qga_back_to_portfolio"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + escaped_connector = escape_markdown_v2(connector) + escaped_quote = escape_markdown_v2(quote) + escaped_header = escape_markdown_v2("⚑ Quantum Grid Allocator - Step 4/5") + + message_text = ( + f"*{escaped_header}*\n\n" + f"🏦 `{escaped_connector}` \\| πŸ’° `{escaped_quote}`" + balance_text + "\n\n" + "*πŸ’° Total Portfolio Value*\n" + "_Select or type an amount \\(will be split across assets\\):_" + ) + + if query and query.message: + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["qga_wizard_message_id"] = query.message.message_id + elif use_message_id: + wizard_chat_id = context.user_data.get("qga_wizard_chat_id", chat_id) + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=use_message_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + try: + await context.bot.delete_message(chat_id=wizard_chat_id, message_id=use_message_id) + except Exception: + pass + new_msg = await context.bot.send_message( + chat_id=wizard_chat_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["qga_wizard_message_id"] = new_msg.message_id + context.user_data["qga_wizard_chat_id"] = wizard_chat_id + +async def handle_qga_wizard_amount(update, context, amount: float) -> None: + query = update.callback_query + config = get_controller_config(context) + config["total_amount_quote"] = amount + set_controller_config(context, config) + + await query.message.edit_text( + r"*⚑ Quantum Grid Allocator \- New Config*" + "\n\n" + r"⏳ *Generating portfolio configuration\.\.\.*" + "\n\n" + r"_Building allocation and grid parameters\.\.\._", + parse_mode="MarkdownV2", + ) + + context.user_data["qga_wizard_step"] = "final" + await _qga_show_final_step(update, context) + + +async def _qga_show_final_step(update, context, interval: str = None) -> None: + """QGA Final Step: Config Summary""" + query = update.callback_query + msg = query.message if query else update.message + chat_id = update.effective_chat.id + config = get_controller_config(context) + + connector = config.get("connector_name", "") + quote = config.get("quote_asset", "FDUSD") + total_amount = config.get("total_amount_quote", 1000) + leverage = config.get("leverage", 1) + position_mode = config.get("position_mode", "HEDGE") + bb_length = config.get("bb_length", 100) + bb_std_dev = config.get("bb_std_dev", 2.0) + interval = config.get("interval", "1s") + + # Portfolio allocation + portfolio = config.get("portfolio_allocation", {}) + if isinstance(portfolio, str): + import json + try: + portfolio = json.loads(portfolio) + except: + portfolio = {} + + # Build portfolio string + portfolio_lines = [] + total_pct = 0 + for asset, pct in portfolio.items(): + pct_float = float(pct) if not isinstance(pct, float) else pct + if pct_float > 0: + total_pct += pct_float + portfolio_lines.append(f" β€’ {asset}: {pct_float*100:.0f}%") + # Non mostrare USDT:0% + + portfolio_str = "\n".join(portfolio_lines) if portfolio_lines else " β€’ None" + + # Generate ID if not set + if not config.get("id"): + existing_configs = context.user_data.get("controller_configs_list", []) + from .controllers.quantum_grid_allocator import generate_id as qga_generate_id + config["id"] = qga_generate_id(config, existing_configs) + + context.user_data["bots_state"] = "qga_wizard_input" + context.user_data["qga_wizard_step"] = "final" + + keyboard = [ + [InlineKeyboardButton("πŸ’Ύ Save Config", callback_data="bots:qga_save")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:qga_back_to_amount"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + # Escapa i valori per MarkdownV2 + escaped_title = escape_markdown_v2(f"⚑ Quantum Grid Allocator - Step 5/5 (Final)") + escaped_connector = escape_markdown_v2(connector) + escaped_quote = escape_markdown_v2(quote) + escaped_position_mode = escape_markdown_v2(position_mode) + escaped_interval = escape_markdown_v2(interval) + + config_text = ( + f"*{escaped_title}*\n\n" + f"*Portfolio Allocation:*\n{portfolio_str}\n\n" + f"`connector_name={escaped_connector}`\n" + f"`quote_asset={escaped_quote}`\n" + f"`total_amount_quote={total_amount:.0f}`\n" + f"`leverage={leverage}`\n" + f"`position_mode={escaped_position_mode}`\n" + f"`base_grid_value_pct={config.get('base_grid_value_pct', 0.08)}`\n" + f"`max_grid_value_pct={config.get('max_grid_value_pct', 0.15)}`\n" + f"`grid_range={config.get('grid_range', 0.002)}`\n" + f"`tp_sl_ratio={config.get('tp_sl_ratio', 0.8)}`\n" + f"`long_only_threshold={config.get('long_only_threshold', 0.2)}`\n" + f"`short_only_threshold={config.get('short_only_threshold', 0.2)}`\n" + f"`hedge_ratio={config.get('hedge_ratio', 2)}`\n" + f"`min_order_amount={config.get('min_order_amount', 5)}`\n" + f"`max_open_orders={config.get('max_open_orders', 2)}`\n" + f"`max_deviation={config.get('max_deviation', 0.05)}`\n" + f"`bb_length={bb_length}`\n" + f"`bb_std_dev={bb_std_dev}`\n" + f"`interval={escaped_interval}`\n\n" + r"_Edit: `field=value`_" + ) + + try: + await msg.edit_text( + text=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["qga_wizard_message_id"] = msg.message_id + except Exception: + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["qga_wizard_message_id"] = new_msg.message_id + + +async def handle_qga_save(update, context) -> None: + """Save Quantum Grid Allocator configuration""" + query = update.callback_query + config = get_controller_config(context) + + # Convert portfolio allocation from dict to string if needed + portfolio = config.get("portfolio_allocation", {}) + if isinstance(portfolio, dict): + import json + config["portfolio_allocation"] = json.dumps(portfolio) + + config_id = config.get("id", "") + chat_id = query.message.chat_id + + try: + await query.message.delete() + except Exception: + pass + + status_msg = await context.bot.send_message( + chat_id=chat_id, + text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + await client.controllers.create_or_update_controller_config(config_id, config) + + for key in ["qga_wizard_step", "qga_wizard_message_id", "qga_wizard_chat_id", + "qga_current_price", "qga_candles", "qga_candles_interval"]: + context.user_data.pop(key, None) + context.user_data.pop("bots_state", None) + + keyboard = [ + [InlineKeyboardButton("Create Another", callback_data="bots:new_quantum_grid_allocator")], + [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + "*Config Saved\\!*\n\n" + "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"QGA save error: {e}", exc_info=True) + keyboard = [ + [InlineKeyboardButton("Try Again", callback_data="bots:qga_save")], + [InlineKeyboardButton("Back", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + format_error_message(f"Failed to save: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +# Back handlers +async def handle_qga_back_to_connector(update, context) -> None: + context.user_data["qga_wizard_step"] = "connector_name" + await _qga_show_connector_step(update, context) + +async def handle_qga_back_to_quote(update, context) -> None: + """Torna allo step quote asset e resetta le allocazioni""" + query = update.callback_query + await query.answer() + + config = get_controller_config(context) + + # Resetta le allocazioni + config["portfolio_allocation"] = {} + set_controller_config(context, config) + + # Resetta lo step + context.user_data["qga_wizard_step"] = "quote_asset" + + # Mostra lo step quote asset + await _qga_show_quote_asset_step(update, context) + +async def handle_qga_back_to_portfolio(update, context) -> None: + """Torna allo step di portfolio allocation""" + query = update.callback_query + await query.answer() + + # Reset dello stato + context.user_data["bots_state"] = "qga_wizard_input" + + # Torna allo step portfolio + await _qga_show_portfolio_step(update, context) + + +async def handle_qga_back_to_grid_params(update, context) -> None: + context.user_data["qga_wizard_step"] = "grid_params" + await _qga_show_grid_params_step(update, context) + + +async def handle_qga_back_to_amount(update, context) -> None: + context.user_data["qga_wizard_step"] = "total_amount_quote" + await _qga_show_amount_step(update, context) + +async def handle_qga_add_prompt(update, context) -> None: + + query = update.callback_query + await query.answer() + context.user_data["bots_state"] = "qga_waiting_for_asset" + current_msg_id = context.user_data.get("qga_wizard_message_id") + wizard_chat_id = context.user_data.get("qga_wizard_chat_id", update.effective_chat.id) + escaped_title = escape_markdown_v2("⚑ Add Asset") + + message_text = ( + f"*{escaped_title}*\n\n" + "Type the asset and percentage in the format:\n" + "`ASSET:percentage`\n\n" + "*Examples:*\n" + "β€’ `SOL:0\\.5` \\(50%\\)\n" + "β€’ `BTC:0\\.3` \\(30%\\)\n\n" + "_Total cannot exceed 100%_" + ) + + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Cancel", callback_data="bots:qga_back_to_portfolio") + ]]), + ) + +async def process_qga_wizard_input(update, context, user_input: str) -> None: + """Process text input during Quantum Grid Allocator wizard""" + step = context.user_data.get("qga_wizard_step") + chat_id = update.effective_chat.id + config = get_controller_config(context) + message_id = context.user_data.get("qga_wizard_message_id") + wizard_chat_id = context.user_data.get("qga_wizard_chat_id", chat_id) + + try: + try: + await update.message.delete() + except Exception: + pass + + if step == "portfolio_allocation": + # Gestisci input diretto per aggiungere asset + user_input = user_input.strip().upper() + if ":" in user_input: + asset, pct = user_input.split(":", 1) + try: + pct_float = float(pct.replace("%", "")) + if pct_float > 1: + pct_float = pct_float / 100 + + if pct_float <= 0: + await context.bot.send_message( + chat_id=wizard_chat_id, + text="❌ Percentage must be positive." + ) + await _qga_show_portfolio_step(update, context, target_message_id=message_id) + return + + portfolio = config.get("portfolio_allocation", {}) + if isinstance(portfolio, str): + import json + try: + portfolio = json.loads(portfolio) + except: + portfolio = {} + + current_total = sum(portfolio.values()) + if current_total + pct_float > 1.0: + remaining = 1.0 - current_total + await context.bot.send_message( + chat_id=wizard_chat_id, + text=f"❌ Cannot add {pct_float*100:.0f}%. Only {remaining*100:.0f}% remaining." + ) + await _qga_show_portfolio_step(update, context, target_message_id=message_id) + return + + portfolio[asset] = pct_float + config["portfolio_allocation"] = portfolio + set_controller_config(context, config) + + await _qga_show_portfolio_step(update, context, target_message_id=message_id) + + except ValueError: + await context.bot.send_message( + chat_id=wizard_chat_id, + text="❌ Invalid format. Use ASSET:percentage (e.g., SOL:0.5)" + ) + await _qga_show_portfolio_step(update, context, target_message_id=message_id) + else: + await context.bot.send_message( + chat_id=wizard_chat_id, + text="❌ Invalid format. Use ASSET:percentage (e.g., SOL:0.5)" + ) + await _qga_show_portfolio_step(update, context, target_message_id=message_id) + return + + elif step == "total_amount_quote": + try: + amount = float(user_input.strip().replace("$", "").replace(",", "")) + config["total_amount_quote"] = amount + set_controller_config(context, config) + context.user_data["qga_wizard_step"] = "final" + await _qga_show_final_step(update, context) + except ValueError: + await context.bot.send_message( + chat_id=wizard_chat_id, + text="❌ Invalid amount. Please enter a number (e.g., 5000)" + ) + + except Exception as e: + logger.error(f"QGA wizard input error: {e}", exc_info=True) + + + +# ============================================ +# STAT ARB V2 WIZARD (versione con asset base + quote assets) +# ============================================ + +async def show_new_stat_arb_v2_form(update, context) -> None: + """Start the Stat Arb V2 wizard - Step 1: Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + # Clear cached data + for key in ["stat_arb_current_price_dom", "stat_arb_current_price_hedge", + "stat_arb_candles_dom", "stat_arb_candles_hedge", "stat_arb_candles_interval", + "stat_arb_analysis"]: + context.user_data.pop(key, None) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + 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}") + + init_new_controller_config(context, "stat_arb_v2") + context.user_data["bots_state"] = "stat_arb_wizard" + context.user_data["stat_arb_wizard_step"] = "connector_name" + context.user_data["stat_arb_wizard_message_id"] = query.message.message_id + context.user_data["stat_arb_wizard_chat_id"] = query.message.chat_id + + await _stat_arb_show_connector_step(update, context) + +async def _stat_arb_show_pair_dom_step(update, context) -> None: + """Step 2: Dominant trading pair (legacy, non piΓΉ usato)""" + # Reindirizza al nuovo step + await _stat_arb_show_base_asset_step(update, context) + + +async def _stat_arb_show_pair_hedge_step(update, context) -> None: + """Step 3: Hedge trading pair (legacy, non piΓΉ usato)""" + # Reindirizza al nuovo step + await _stat_arb_show_quote_asset_1_step(update, context) + + +async def _stat_arb_show_connector_step(update, context) -> None: + """Step 1: Select Connector (unico exchange)""" + query = update.callback_query + chat_id = update.effective_chat.id + + try: + client, server_name = await get_bots_client(chat_id, context.user_data) + cex_connectors = await get_available_cex_connectors( + context.user_data, client, server_name=server_name + ) + + if not cex_connectors: + keyboard = [ + [InlineKeyboardButton("πŸ”‘ Configure API Keys", callback_data="config_api_keys")], + [InlineKeyboardButton("Β« Back", callback_data="bots:main_menu")], + ] + await query.message.edit_text( + r"*πŸ“Š Stat Arb V2 \- New Config*" + "\n\n" + r"⚠️ No CEX connectors available\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + keyboard = [] + row = [] + for connector in cex_connectors: + row.append(InlineKeyboardButton(f"🏦 {connector}", callback_data=f"bots:stat_arb_connector:{connector}")) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + await query.message.edit_text( + r"*πŸ“Š Stat Arb V2*" + "\n\n" + r"Statistical arbitrage between the same base asset quoted in two different currencies\." + "\n\n" + r"Example: KCS quoted in USDT vs KCS quoted in BTC" + "\n\n" + r"─────────────────────────" + "\n\n" + r"*Step 1: Select Exchange*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"Stat Arb connector step error: {e}", exc_info=True) + keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]] + await query.message.edit_text( + format_error_message(f"Error: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_stat_arb_wizard_connector(update, context, connector: str) -> None: + config = get_controller_config(context) + config["connector_name"] = connector + set_controller_config(context, config) + context.user_data["stat_arb_wizard_step"] = "base_asset" + await _stat_arb_show_base_asset_step(update, context) + + +async def _stat_arb_show_base_asset_step(update, context) -> None: + """Step 2: Select Base Asset (the common token) - input diretto in chat""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + + context.user_data["bots_state"] = "stat_arb_wizard_input" + context.user_data["stat_arb_wizard_step"] = "base_asset" + + # Recent base assets from existing configs + existing_configs = context.user_data.get("controller_configs_list", []) + recent_assets = [] + seen = set() + for cfg in reversed(existing_configs): + asset = cfg.get("base_asset", "") + if asset and asset not in seen: + seen.add(asset) + recent_assets.append(asset) + if len(recent_assets) >= 6: + break + + keyboard = [] + + # Common base assets suggestions + common_assets = ["BTC", "ETH", "SOL", "KCS", "BNB", "XRP"] + row = [] + for asset in common_assets: + row.append(InlineKeyboardButton(asset, callback_data=f"bots:stat_arb_base_asset:{asset}")) + if len(row) == 3: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + if recent_assets: + keyboard.append([InlineKeyboardButton("β€” Recent β€”", callback_data="bots:noop")]) + row = [] + for asset in recent_assets[:6]: + row.append(InlineKeyboardButton(asset, callback_data=f"bots:stat_arb_base_asset:{asset}")) + if len(row) == 3: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + # Nessun pulsante "Type custom" - l'utente scrive direttamente in chat + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 7 if is_perp else 6 + current_step = 2 + + await query.message.edit_text( + rf"*πŸ“Š Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n" + r"πŸͺ™ *Base Asset*" + "\n\n" + r"The common token traded on both pairs \(e\.g\. KCS, BTC, ETH\):" + "\n\n" + r"*Select a button or type the asset name in chat:*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_stat_arb_base_asset(update, context, asset: str) -> None: + """Handle base asset selection - verifica subito le quote disponibili""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + connector = config.get("connector_name", "") + + config["base_asset"] = asset.upper() + set_controller_config(context, config) + + # Mostra messaggio di caricamento + await query.message.edit_text( + rf"*πŸ“Š Stat Arb V2*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸͺ™ `{escape_markdown_v2(asset.upper())}`" + "\n\n" + r"⏳ *Checking available quote assets\.\.\.*" + "\n\n" + r"_Please wait, verifying which pairs have candle data_", + parse_mode="MarkdownV2", + ) + + # Ottieni e verifica le quote disponibili + valid_quotes = [] + try: + client, _ = await get_bots_client(chat_id, context.user_data) + trading_rules = await get_trading_rules(context.user_data, client, connector) + + prefix = f"{asset.upper()}-" + all_quotes = [] + + # Raccogli tutte le quote disponibili + for pair, rules in trading_rules.items(): + if pair.startswith(prefix): + quote = pair.replace(prefix, "") + all_quotes.append(quote) + + logger.info(f"All quotes for {asset} on {connector}: {all_quotes}") + + # Verifica quali hanno dati candela + if all_quotes: + for quote in all_quotes: + pair = f"{asset.upper()}-{quote}" + try: + try: + test_candles = await asyncio.wait_for( + fetch_candles(client, connector, pair, interval="5m", max_records=420), + timeout=15.0 + ) + except (asyncio.TimeoutError, Exception): + test_candles = None + if test_candles: + valid_quotes.append(quote) + logger.info(f"βœ“ {pair} has candle data") + else: + logger.warning(f"βœ— {pair} has NO candle data") + except Exception as e: + logger.warning(f"βœ— {pair} error: {e}") + + valid_quotes.sort() + + # CONTROLLO: deve avere almeno 2 quote valide + if len(valid_quotes) < 2: + await query.message.edit_text( + text=rf"*πŸ“Š Stat Arb V2 \- Error*" + "\n\n" + f"❌ `{asset.upper()}` has only {len(valid_quotes)} valid quote(s)\\.\n\n" + r"Please choose a different base asset with at least 2 trading pairs\\.\n\n" + r"*Examples:* BTC, ETH, SOL, KCS", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_base_asset") + ]]), + ) + return + + # Salva le quote valide nel context per usarle dopo + context.user_data["stat_arb_valid_quotes"] = valid_quotes + logger.info(f"Valid quotes for {asset}: {valid_quotes}") + + except Exception as e: + logger.error(f"Error checking quotes: {e}") + valid_quotes = ["USDT", "USDC"] # fallback + + # Se nessuna quota valida, usa fallback + if not valid_quotes: + valid_quotes = ["USDT", "USDC"] + + # CONTROLLO PER IL FALLBACK + if len(valid_quotes) < 2: + await query.message.edit_text( + text=rf"*πŸ“Š Stat Arb V2 \- Error*" + "\n\n" + f"❌ `{asset.upper()}` has only {len(valid_quotes)} valid quote(s)\\.\n\n" + r"Please choose a different base asset with at least 2 trading pairs\\.\n\n" + r"*Examples:* BTC, ETH, SOL, KCS", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_base_asset") + ]]), + ) + return + + # Vai allo step successivo + context.user_data["stat_arb_wizard_step"] = "quote_asset_1" + await _stat_arb_show_quote_asset_1_step(update, context) + +async def _stat_arb_show_quote_asset_1_step(update, context) -> None: + """Step 3: First Quote Asset""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + base_asset = config.get("base_asset", "") + + # Recupera le quote giΓ  verificate + valid_quotes = context.user_data.get("stat_arb_valid_quotes", ["USDT", "USDC"]) + + context.user_data["bots_state"] = "stat_arb_wizard_input" + context.user_data["stat_arb_wizard_step"] = "quote_asset_1" + + keyboard = [] + row = [] + for quote in valid_quotes[:12]: + row.append(InlineKeyboardButton(quote, callback_data=f"bots:stat_arb_quote_1:{quote}")) + if len(row) == 3: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + # Nessun pulsante "Type custom" - l'utente scrive direttamente in chat + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_base_asset"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 7 if is_perp else 6 + current_step = 3 + + await query.message.edit_text( + rf"*πŸ“Š Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸͺ™ `{escape_markdown_v2(base_asset)}`" + "\n\n" + f"πŸ“ˆ *First Quote Asset*" + "\n\n" + rf"The quote currency for `{base_asset}-XXX`:" + "\n\n" + rf"*Available quotes with candle data:* {len(valid_quotes)} found" + "\n\n" + r"*Select a button or type the quote asset in chat:*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_stat_arb_quote_asset_1(update, context, quote: str) -> None: + """Handle first quote asset selection""" + config = get_controller_config(context) + base_asset = config.get("base_asset", "") + config["first_quote_asset"] = quote.upper() + config["trading_pair_dominant"] = f"{base_asset}-{quote.upper()}" + set_controller_config(context, config) + context.user_data["stat_arb_wizard_step"] = "quote_asset_2" + await _stat_arb_show_quote_asset_2_step(update, context) + +async def _stat_arb_show_quote_asset_2_step(update, context) -> None: + """Step 4: Second Quote Asset""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + base_asset = config.get("base_asset", "") + first_quote = config.get("first_quote_asset", "") + + valid_quotes = context.user_data.get("stat_arb_valid_quotes", ["USDT", "USDC"]) + remaining_quotes = [q for q in valid_quotes if q != first_quote] + + context.user_data["bots_state"] = "stat_arb_wizard_input" + context.user_data["stat_arb_wizard_step"] = "quote_asset_2" + + keyboard = [] + row = [] + for quote in remaining_quotes[:12]: + row.append(InlineKeyboardButton(quote, callback_data=f"bots:stat_arb_quote_2:{quote}")) + if len(row) == 3: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + # Nessun pulsante "Type custom" - l'utente scrive direttamente in chat + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_quote_1"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 7 if is_perp else 6 + current_step = 4 + + await query.message.edit_text( + rf"*πŸ“Š Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸͺ™ `{escape_markdown_v2(base_asset)}`" + "\n\n" + rf"*First quote selected:* `{first_quote}`" + "\n\n" + f"πŸ“‰ *Second Quote Asset*" + "\n\n" + rf"The quote currency for `{base_asset}-XXX` \(different from {first_quote}\):" + "\n\n" + rf"*Available quotes:* {len(remaining_quotes)} found" + "\n\n" + r"*Select a button or type the quote asset in chat:*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_stat_arb_quote_asset_2(update, context, quote: str) -> None: + """Handle second quote asset selection""" + config = get_controller_config(context) + base_asset = config.get("base_asset", "") + config["second_quote_asset"] = quote.upper() + config["trading_pair_hedge"] = f"{base_asset}-{quote.upper()}" + set_controller_config(context, config) + + connector = config.get("connector_name", "") + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + + if is_perp: + context.user_data["stat_arb_wizard_step"] = "leverage" + await _stat_arb_show_leverage_step(update, context) + else: + config["leverage"] = 1 + config["position_mode"] = "HEDGE" + set_controller_config(context, config) + context.user_data["stat_arb_wizard_step"] = "total_amount_quote" + await _stat_arb_show_amount_step(update, context) + +async def _stat_arb_show_leverage_step(update, context) -> None: + """Step 5 (perp only): Leverage""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + dom_pair = config.get("trading_pair_dominant", "") + hedge_pair = config.get("trading_pair_hedge", "") + + context.user_data["bots_state"] = "stat_arb_wizard_input" + context.user_data["stat_arb_wizard_step"] = "leverage" + + keyboard = [ + [ + InlineKeyboardButton("1x", callback_data="bots:stat_arb_leverage:1"), + InlineKeyboardButton("5x", callback_data="bots:stat_arb_leverage:5"), + InlineKeyboardButton("10x", callback_data="bots:stat_arb_leverage:10"), + ], + [ + InlineKeyboardButton("20x", callback_data="bots:stat_arb_leverage:20"), + InlineKeyboardButton("50x", callback_data="bots:stat_arb_leverage:50"), + InlineKeyboardButton("75x", callback_data="bots:stat_arb_leverage:75"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_quote_2"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 7 if is_perp else 6 + current_step = 5 if is_perp else 5 + + await query.message.edit_text( + rf"*πŸ“Š Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸͺ™ `{escape_markdown_v2(config.get('base_asset', ''))}`" + "\n\n" + f"πŸ“ˆ *First pair:*`{escape_markdown_v2(dom_pair)}`" + "\n" + f"πŸ“‰ *Second pair:*`{escape_markdown_v2(hedge_pair)}`" + "\n\n" + r"⚑ *Select Leverage*" + "\n" + r"_Or type a value \(e\.g\. 20\)_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + +async def handle_stat_arb_wizard_leverage(update, context, leverage: int) -> None: + config = get_controller_config(context) + config["leverage"] = leverage + config["position_mode"] = "HEDGE" + set_controller_config(context, config) + context.user_data["stat_arb_wizard_step"] = "total_amount_quote" + await _stat_arb_show_amount_step(update, context) + + +async def _stat_arb_show_amount_step(update, context) -> None: + """Step 6: Total Amount Quote""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + connector = config.get("connector_name", "") + dom_pair = config.get("trading_pair_dominant", "") + hedge_pair = config.get("trading_pair_hedge", "") + leverage = config.get("leverage", 1) + + context.user_data["bots_state"] = "stat_arb_wizard_input" + context.user_data["stat_arb_wizard_step"] = "total_amount_quote" + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 7 if is_perp else 6 + current_step = 6 if is_perp else 5 + + # Fetch balance + balance_text = "" + try: + client, _ = await get_bots_client(chat_id, context.user_data) + balances = await get_cex_balances( + context.user_data, client, "master_account", ttl=30 + ) + + # Flexible matching come in Grid Strike + 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", "") + if bal_lower == connector_lower or bal_base == connector_base: + connector_balances = bal_list + break + + if connector_balances: + relevant_balances = [] + quote = pair.split("-")[1] if "-" in pair else "USDT" + for bal in connector_balances: + token = bal.get("token", bal.get("asset", "")) + available = bal.get("units", bal.get("available_balance", bal.get("free", 0))) + if token and token.upper() == quote.upper(): + try: + available_float = float(available) + if available_float > 0: + balance_text = f"\n\nπŸ’° Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`" + break + except (ValueError, TypeError): + continue + except Exception as e: + logger.warning(f"Could not fetch balances for Stat Arb amount step: {e}") + + # CORREZIONE: back corretto per spot + back_callback = "bots:stat_arb_back_to_leverage" if is_perp else "bots:stat_arb_back_to_quote_2" + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:stat_arb_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:stat_arb_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:stat_arb_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:stat_arb_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:stat_arb_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:stat_arb_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data=back_callback), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| πŸͺ™ `{escape_markdown_v2(config.get('base_asset', ''))}`" + if is_perp: + header_info += f" \\| ⚑ `{leverage}x`" + + message_text = ( + rf"*πŸ“Š Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n" + + header_info + balance_text + "\n\n" + f"πŸ“ˆ `{escape_markdown_v2(dom_pair)}` vs πŸ“‰ `{escape_markdown_v2(hedge_pair)}`" + "\n\n" + r"πŸ’° *Total Amount \(Quote\)*" + "\n" + r"_Select or type an amount \(will be split between legs via hedge ratio\):_" + ) + + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["stat_arb_wizard_message_id"] = query.message.message_id + + +async def handle_stat_arb_wizard_amount(update, context, amount: float) -> None: + query = update.callback_query + config = get_controller_config(context) + config["total_amount_quote"] = amount + set_controller_config(context, config) + + await query.message.edit_text( + r"*πŸ“Š Stat Arb V2 \- New Config*" + "\n\n" + r"⏳ *Loading cointegration analysis\.\.\.*" + "\n\n" + r"_Fetching market data and computing spread\.\.\._", + parse_mode="MarkdownV2", + ) + + context.user_data["stat_arb_wizard_step"] = "final" + await _stat_arb_show_final_step(update, context) + +async def _stat_arb_show_final_step(update, context, interval: str = None) -> None: + """Final Step: Show analysis + config summary with suggested parameters""" + import asyncio + from .controllers.stat_arb_v2 import StatArbV2Controller + from .controllers.stat_arb_v2.config import generate_id as stat_arb_generate_id + from .controllers.stat_arb_v2.analysis import analyze_candles_for_stat_arb, format_stat_arb_summary + query = update.callback_query + msg = query.message if query else update.message + chat_id = update.effective_chat.id + config = get_controller_config(context) + + connector = config.get("connector_name", "") + dom_pair = config.get("trading_pair_dominant", "") + hedge_pair = config.get("trading_pair_hedge", "") + total_amount = config.get("total_amount_quote", 1000) + leverage = config.get("leverage", 1) + + if interval is None: + interval = context.user_data.get("stat_arb_chart_interval", config.get("interval", "5m")) + context.user_data["stat_arb_chart_interval"] = interval + config["interval"] = interval + set_controller_config(context, config) + + # Mostra messaggio di caricamento + try: + await msg.edit_text( + r"*πŸ“Š Stat Arb V2 \- New Config*" + "\n\n" + f"⏳ Fetching market data for `{escape_markdown_v2(dom_pair)}` and `{escape_markdown_v2(hedge_pair)}`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + except Exception: + pass + + # Inizializza variabili per i dati + dom_list = [] + hedge_list = [] + analysis = {} + combined_data = [] + + # Fetch candles for both pairs + try: + client, _ = await get_bots_client(chat_id, context.user_data) + candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "") + + if not candles_connector or len(candles_connector) < 3: + candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "") + if not candles_connector: + candles_connector = "kucoin" + + logger.info(f"Stat Arb: Trading on {connector}, using candles from {candles_connector}") + + # Prova entrambi i formati per la dominant pair + dom_variants = [dom_pair, dom_pair.replace("-", "/")] + candles_dom = None + for variant in dom_variants: + try: + logger.info(f"Trying to fetch candles for {variant} from {candles_connector}") + candles_dom = await asyncio.wait_for( + fetch_candles(client, candles_connector, variant, interval=interval, max_records=420), + timeout=15.0 + ) + if candles_dom: + candles_data = candles_dom.get("data", []) if isinstance(candles_dom, dict) else (candles_dom or []) + if candles_data and len(candles_data) > 0: + logger.info(f"Successfully fetched {len(candles_data)} candles for {variant}") + break + else: + candles_dom = None + else: + candles_dom = None + except asyncio.TimeoutError: + logger.warning(f"Timeout fetching {variant}") + candles_dom = None + except Exception as e: + logger.warning(f"Failed to fetch {variant}: {e}") + candles_dom = None + + # Prova entrambi i formati per la hedge pair + hedge_variants = [hedge_pair, hedge_pair.replace("-", "/")] + candles_hedge = None + for variant in hedge_variants: + try: + logger.info(f"Trying to fetch candles for {variant} from {candles_connector}") + candles_hedge = await asyncio.wait_for( + fetch_candles(client, candles_connector, variant, interval=interval, max_records=420), + timeout=15.0 + ) + if candles_hedge: + candles_data = candles_hedge.get("data", []) if isinstance(candles_hedge, dict) else (candles_hedge or []) + if candles_data and len(candles_data) > 0: + logger.info(f"Successfully fetched {len(candles_data)} candles for {variant}") + break + else: + candles_hedge = None + else: + candles_hedge = None + except asyncio.TimeoutError: + logger.warning(f"Timeout fetching {variant}") + candles_hedge = None + except Exception as e: + logger.warning(f"Failed to fetch {variant}: {e}") + candles_hedge = None + + if not candles_dom or not candles_hedge: + missing = [] + if not candles_dom: + missing.append(f"{dom_pair}") + if not candles_hedge: + missing.append(f"{hedge_pair}") + logger.warning(f"Could not fetch candles for: {', '.join(missing)}") + analysis = {"error": f"Could not fetch candles for: {', '.join(missing)}"} + else: + dom_list = candles_dom.get("data", []) if isinstance(candles_dom, dict) else (candles_dom or []) + hedge_list = candles_hedge.get("data", []) if isinstance(candles_hedge, dict) else (candles_hedge or []) + + logger.info(f"Stat Arb: Loaded {len(dom_list)} candles for {dom_pair}, {len(hedge_list)} candles for {hedge_pair}") + + # COMBINA I DATI PER IL CHART + # Allinea le due serie per timestamp + dom_by_ts = {c.get('timestamp'): c for c in dom_list} + hedge_by_ts = {c.get('timestamp'): c for c in hedge_list} + + # Timestamp comuni + common_timestamps = set(dom_by_ts.keys()) & set(hedge_by_ts.keys()) + common_timestamps = sorted(list(common_timestamps)) + # Se l'intervallo Γ¨ 15m, limita a 24 ore (96 candele) + current_interval = config.get("interval", "5m") + max_candles = None + if current_interval == "15m": + max_candles = 96 # 24 ore * 4 candele/ora = 96 + elif current_interval == "1h": + max_candles = 168 # 7 giorni (opzionale) + # Aggiungi altri limiti se necessario + + if max_candles and len(common_timestamps) > max_candles: + # Prendi le ultime N candele + common_timestamps = common_timestamps[-max_candles:] + logger.info(f"Limited chart to last {max_candles} candles ({len(common_timestamps)}) for interval {current_interval}") + for ts in common_timestamps: + dom_candle = dom_by_ts[ts] + hedge_candle = hedge_by_ts[ts] + combined_data.append({ + 'timestamp': ts, + 'close_dom': float(dom_candle.get('close', 0)), + 'close_hedge': float(hedge_candle.get('close', 0)), + 'open_dom': float(dom_candle.get('open', 0)), + 'open_hedge': float(hedge_candle.get('open', 0)), + 'high_dom': float(dom_candle.get('high', 0)), + 'high_hedge': float(hedge_candle.get('high', 0)), + 'low_dom': float(dom_candle.get('low', 0)), + 'low_hedge': float(hedge_candle.get('low', 0)), + }) + + logger.info(f"Combined {len(combined_data)} candles for chart") + if len(combined_data) == 0: + logger.warning(f"No common timestamps found! dom_list samples: {len(dom_list)}, hedge_list samples: {len(hedge_list)}") + if dom_list: + logger.info(f"First dom timestamp: {dom_list[0].get('timestamp')}") + if hedge_list: + logger.info(f"First hedge timestamp: {hedge_list[0].get('timestamp')}") + # Run analysis + # Calcola lookback dinamico (usa il minimo tra lookback configurato e dati disponibili) + max_lookback = config.get("lookback_period", 100) + actual_lookback = min(max_lookback, len(dom_list), len(hedge_list)) + # Assicura almeno 30 candele (se possibile) + if actual_lookback < 30 and len(dom_list) >= 30 and len(hedge_list) >= 30: + actual_lookback = 30 + + logger.info(f"Stat Arb: Using lookback={actual_lookback} candles (config={max_lookback}, dom={len(dom_list)}, hedge={len(hedge_list)})") + + # Run analysis + analysis = analyze_candles_for_stat_arb(dom_list, hedge_list, lookback=actual_lookback) + + if "error" not in analysis: + # Auto‑suggest parameters if not already set + if config.get("entry_threshold", 2.0) == 2.0: + config["entry_threshold"] = analysis.get("suggested_entry_threshold", 2.0) + if config.get("take_profit", 0.0008) == 0.0008: + config["take_profit"] = analysis.get("suggested_take_profit", 0.0008) + + set_controller_config(context, config) + + # Generate config ID if not set + if not config.get("id"): + existing_configs = context.user_data.get("controller_configs_list", []) + config["id"] = stat_arb_generate_id(config, existing_configs) + + context.user_data["stat_arb_analysis"] = analysis + + except Exception as e: + logger.error(f"Stat Arb analysis failed: {e}", exc_info=True) + analysis = {"error": str(e)} + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + final_step = 7 if is_perp else 6 + + # Build interval buttons + interval_options = ["15m", "1h", "4h", "1d"] + interval_row = [ + InlineKeyboardButton( + f"βœ“ {opt}" if opt == interval else opt, + callback_data=f"bots:stat_arb_interval:{opt}" + ) + for opt in interval_options + ] + + keyboard = [ + interval_row, + [InlineKeyboardButton("πŸ’Ύ Save Config", callback_data="bots:stat_arb_save")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_amount"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + # Build config text (copyable) + config_block = ( + f"id: {config.get('id', '')}\n" + f"connector_name: {connector}\n" + f"trading_pair_dominant: {dom_pair}\n" + f"trading_pair_hedge: {hedge_pair}\n" + f"leverage: {leverage}\n" + f"total_amount_quote: {total_amount:.0f}\n" + f"interval: {interval}\n" + f"lookback_period: {config.get('lookback_period', 300)}\n" + f"entry_threshold: {config.get('entry_threshold', 2.0)}\n" + f"take_profit: {config.get('take_profit', 0.0008)}\n" + f"tp_global: {config.get('tp_global', 0.01)}\n" + f"sl_global: {config.get('sl_global', 0.05)}\n" + f"min_amount_quote: {config.get('min_amount_quote', 10)}\n" + f"quoter_spread: {config.get('quoter_spread', 0.0001)}\n" + f"quoter_cooldown: {config.get('quoter_cooldown', 30)}\n" + f"quoter_refresh: {config.get('quoter_refresh', 10)}\n" + f"max_orders_placed_per_side: {config.get('max_orders_placed_per_side', 2)}\n" + f"max_orders_filled_per_side: {config.get('max_orders_filled_per_side', 2)}\n" + f"max_position_deviation: {config.get('max_position_deviation', 0.1)}\n" + f"use_dynamic_hedge_ratio: {config.get('use_dynamic_hedge_ratio', True)}\n" + f"pos_hedge_ratio: {config.get('pos_hedge_ratio', 1.0)}\n" + f"max_dynamic_hedge_ratio: {config.get('max_dynamic_hedge_ratio', 3.0)}\n" + f"min_dynamic_hedge_ratio: {config.get('min_dynamic_hedge_ratio', 0.2)}\n" + f"min_r_squared: {config.get('min_r_squared', 0.70)}\n" + f"adf_pvalue_threshold: {config.get('adf_pvalue_threshold', 0.05)}" + ) + + # Add analysis summary + analysis_text = format_stat_arb_summary(analysis) if "error" not in analysis else f"⚠️ Analysis error: {analysis.get('error', 'Unknown error')}" + + escaped_pair = escape_markdown_v2(dom_pair) + config_text = ( + rf"*πŸ“Š Stat Arb V2 \- Step {final_step}/{final_step} \(Final\)*" + "\n\n" + f"*{escaped_pair}* vs *{escape_markdown_v2(hedge_pair)}*\n\n" + f"```\n{config_block}\n```\n\n" + f"```\n{analysis_text}\n```\n\n" + r"_Edit: `field=value`_" + ) + + # Genera il chart con i dati combinati + try: + from .controllers.stat_arb_v2.chart import generate_chart as stat_arb_chart + + # Usa i dati combinati se disponibili, altrimenti usa dom_list + chart_data = combined_data if combined_data else (dom_list if dom_list else []) + + if chart_data and len(chart_data) > 0: + logger.info(f"Generating chart with {len(chart_data)} candles") + chart_bytes = stat_arb_chart(config, chart_data, current_price=0) + if chart_bytes: + try: + await msg.delete() + except Exception: + pass + new_msg = await context.bot.send_photo( + chat_id=chat_id, + photo=chart_bytes, + caption=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["stat_arb_wizard_message_id"] = new_msg.message_id + context.user_data["stat_arb_wizard_chat_id"] = chat_id + return + except Exception as e: + logger.warning(f"Chart generation failed: {e}") + + # Fallback: send text only + try: + await msg.edit_text( + text=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["stat_arb_wizard_message_id"] = msg.message_id + except Exception: + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["stat_arb_wizard_message_id"] = new_msg.message_id + + +async def handle_stat_arb_save(update, context) -> None: + """Save Stat Arb V2 config""" + query = update.callback_query + config = get_controller_config(context) + + # Clean up temporary fields + config.pop("candles_config", None) + config.pop("manual_kill_switch", None) + config["position_mode"] = "HEDGE" + + config_id = config.get("id", "") + chat_id = query.message.chat_id + + try: + await query.message.delete() + except Exception: + pass + + status_msg = await context.bot.send_message( + chat_id=chat_id, + text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + await client.controllers.create_or_update_controller_config(config_id, config) + + # Clean wizard state + for key in ["stat_arb_wizard_step", "stat_arb_wizard_message_id", "stat_arb_wizard_chat_id", + "stat_arb_current_price_dom", "stat_arb_current_price_hedge", + "stat_arb_candles_dom", "stat_arb_candles_hedge", "stat_arb_candles_interval", + "stat_arb_analysis"]: + context.user_data.pop(key, None) + context.user_data.pop("bots_state", None) + + keyboard = [ + [InlineKeyboardButton("Create Another", callback_data="bots:new_stat_arb_v2")], + [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + "*Config Saved\\!*\n\n" + "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"Stat Arb save error: {e}", exc_info=True) + keyboard = [ + [InlineKeyboardButton("Try Again", callback_data="bots:stat_arb_save")], + [InlineKeyboardButton("Back", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + format_error_message(f"Failed to save: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_stat_arb_back_to_connector(update, context) -> None: + context.user_data["stat_arb_wizard_step"] = "connector_name" + await _stat_arb_show_connector_step(update, context) + + +async def handle_stat_arb_back_to_pair_dom(update, context) -> None: + context.user_data["stat_arb_wizard_step"] = "trading_pair_dominant" + await _stat_arb_show_pair_dom_step(update, context) + +async def handle_stat_arb_back_to_leverage(update, context) -> None: + context.user_data["stat_arb_wizard_step"] = "leverage" + await _stat_arb_show_leverage_step(update, context) + + +async def handle_stat_arb_back_to_amount(update, context) -> None: + context.user_data["stat_arb_wizard_step"] = "total_amount_quote" + await _stat_arb_show_amount_step(update, context) + +async def handle_stat_arb_back_to_base_asset(update, context) -> None: + context.user_data["stat_arb_wizard_step"] = "base_asset" + await _stat_arb_show_base_asset_step(update, context) + +async def handle_stat_arb_interval_change(update, context, interval: str) -> None: + """Change chart interval and refresh analysis""" + query = update.callback_query + chat_id = update.effective_chat.id + + # Aggiorna l'intervallo + context.user_data["stat_arb_candles_dom"] = None + context.user_data["stat_arb_candles_hedge"] = None + context.user_data["stat_arb_candles_interval"] = interval + context.user_data["stat_arb_chart_interval"] = interval + + # Non passare update, usa query per editare il messaggio corrente + # Crea un nuovo update fittizio con il messaggio corrente + fake_update = type('FakeUpdate', (), { + 'callback_query': query, + 'effective_chat': update.effective_chat + })() + + await _stat_arb_show_final_step(fake_update, context, interval=interval) + +async def handle_stat_arb_back_to_quote_1(update, context) -> None: + """Go back to first quote asset step""" + context.user_data["stat_arb_wizard_step"] = "quote_asset_1" + await _stat_arb_show_quote_asset_1_step(update, context) + + +async def handle_stat_arb_back_to_quote_2(update, context) -> None: + """Go back to second quote asset step""" + context.user_data["stat_arb_wizard_step"] = "quote_asset_2" + await _stat_arb_show_quote_asset_2_step(update, context) + +# Helper for pair suggestions +async def _show_stat_arb_pair_suggestions( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + input_pair: str, + error_msg: str, + suggestions: list, + connector: str, + role: str, # "dom" or "hedge" +) -> None: + message_id = context.user_data.get("stat_arb_wizard_message_id") + chat_id = context.user_data.get("stat_arb_wizard_chat_id") + wizard_chat_id = context.user_data.get("stat_arb_wizard_chat_id", update.effective_chat.id) + + help_text = f"❌ *{escape_markdown_v2(error_msg)}*\n\n" + if suggestions: + help_text += "πŸ’‘ *Did you mean:*\n" + else: + help_text += "_No similar pairs found\\._\n" + + keyboard = [] + for pair in suggestions[:4]: + keyboard.append([InlineKeyboardButton(f"πŸ“ˆ {pair}", callback_data=f"bots:stat_arb_pair_{role}_select:{pair}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data=f"bots:stat_arb_back_to_pair_{role}")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + reply_markup = InlineKeyboardMarkup(keyboard) + + if message_id and chat_id: + try: + await context.bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=help_text, + parse_mode="MarkdownV2", + reply_markup=reply_markup, + ) + except Exception as e: + logger.debug(f"Could not update stat arb wizard message: {e}") + else: + await update.effective_chat.send_message( + help_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + +async def _show_stat_arb_pair_format_error(update, context, connector: str, role: str) -> None: + """Mostra errore per formato coppia non valido""" + message_id = context.user_data.get("stat_arb_wizard_message_id") + wizard_chat_id = context.user_data.get("stat_arb_wizard_chat_id", update.effective_chat.id) + + keyboard = [ + [InlineKeyboardButton("⬅️ Back", callback_data=f"bots:stat_arb_back_to_pair_{role}")] + ] + + role_label = "dominant" if role == "dom" else "hedge" + + err_text = ( + r"*πŸ“Š Stat Arb V2 \- Format Error*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}`\n\n" + r"⚠️ *Invalid format\.* Use `BASE\-QUOTE` \(e\.g\. `BTC\-USDT`\)\n\n" + f"Please type the {role_label} pair again:" + ) + + if message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=err_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"Error editing message in format error: {e}") + # Fallback: invia nuovo messaggio + msg = await context.bot.send_message( + chat_id=update.effective_chat.id, + text=err_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["stat_arb_wizard_message_id"] = msg.message_id + else: + msg = await context.bot.send_message( + chat_id=update.effective_chat.id, + text=err_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["stat_arb_wizard_message_id"] = msg.message_id + +async def process_stat_arb_wizard_input(update, context, user_input: str) -> None: + """Process text input during Stat Arb V2 wizard""" + step = context.user_data.get("stat_arb_wizard_step") + chat_id = update.effective_chat.id + config = get_controller_config(context) + message_id = context.user_data.get("stat_arb_wizard_message_id") + wizard_chat_id = context.user_data.get("stat_arb_wizard_chat_id", chat_id) + + try: + try: + await update.message.delete() + except Exception: + pass + + # ========== GESTISCI INPUT MANUALE PER BASE ASSET ========== + if step == "base_asset": + asset = user_input.upper().strip() + if not asset: + # Ricostruisci lo step base_asset direttamente + connector = config.get("connector_name", "") + keyboard = [] + common_assets = ["BTC", "ETH", "SOL", "KCS", "BNB", "XRP"] + row = [] + for a in common_assets: + row.append(InlineKeyboardButton(a, callback_data=f"bots:stat_arb_base_asset:{a}")) + if len(row) == 3: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Step 2/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n" + r"πŸͺ™ *Base Asset*" + "\n\n" + r"The common token traded on both pairs \(e\.g\. KCS, BTC, ETH\):" + "\n\n" + r"*Select a button or type the asset name in chat:*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + # Validazione base: deve essere un token valido (solo lettere) + if not asset.isalpha(): + connector = config.get("connector_name", "") + keyboard = [] + common_assets = ["BTC", "ETH", "SOL", "KCS", "BNB", "XRP"] + row = [] + for a in common_assets: + row.append(InlineKeyboardButton(a, callback_data=f"bots:stat_arb_base_asset:{a}")) + if len(row) == 3: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Step 2/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n" + r"πŸͺ™ *Base Asset*" + "\n\n" + r"The common token traded on both pairs \(e\.g\. KCS, BTC, ETH\):" + "\n\n" + r"*Select a button or type the asset name in chat:*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + connector = config.get("connector_name", "") + + # Mostra messaggio di caricamento + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸͺ™ `{escape_markdown_v2(asset)}`" + "\n\n" + r"⏳ *Checking available quote assets\.\.\.*" + "\n\n" + r"_Please wait, verifying which pairs have candle data_", + parse_mode="MarkdownV2", + ) + + # Salva il base asset + config["base_asset"] = asset + set_controller_config(context, config) + + # Verifica le quote disponibili + valid_quotes = [] + try: + client, _ = await get_bots_client(chat_id, context.user_data) + trading_rules = await get_trading_rules(context.user_data, client, connector) + + prefix = f"{asset}-" + all_quotes = [] + + for pair, rules in trading_rules.items(): + if pair.startswith(prefix): + quote = pair.replace(prefix, "") + all_quotes.append(quote) + + logger.info(f"All quotes for {asset} on {connector}: {all_quotes}") + + if all_quotes: + for quote in all_quotes[:20]: + pair = f"{asset}-{quote}" + try: + found = False + for test_interval in ["1d", "4h", "1h"]: + try: + test_candles = await asyncio.wait_for( + fetch_candles(client, connector, pair, interval=test_interval, max_records=5), + timeout=15.0 + ) + if test_candles: + candles_data = test_candles.get("data", []) if isinstance(test_candles, dict) else (test_candles or []) + # Richiede almeno 3 candele per considerare la coppia valida + if len(candles_data) >= 3: + valid_quotes.append(quote) + logger.info(f"βœ“ {pair} has candle data at {test_interval}") + found = True + break + except Exception as e: + logger.warning(f"βœ— {pair} failed at {test_interval}: {e}") + + if not found: + logger.warning(f"βœ— {pair} has NO candle data at any interval") + if test_candles: + valid_quotes.append(quote) + logger.info(f"βœ“ {pair} has candle data") + else: + logger.warning(f"βœ— {pair} has NO candle data") + except Exception as e: + logger.warning(f"βœ— {pair} error: {e}") + + + valid_quotes.sort() + context.user_data["stat_arb_valid_quotes"] = valid_quotes + logger.info(f"Valid quotes for {asset}: {valid_quotes}") + # CONTROLLO: deve avere almeno 2 quote valide + if len(valid_quotes) < 2: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Error*" + "\n\n" + f"❌ `{asset}` has only {len(valid_quotes)} valid quote(s)\\.\n\n" + r"Please choose a different base asset with at least 2 trading pairs\\.\n\n" + r"*Examples:* BTC, ETH, SOL, KCS", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_base_asset") + ]]), + ) + return + + except Exception as e: + logger.error(f"Error checking quotes: {e}") + valid_quotes = ["USDT", "USDC"] + context.user_data["stat_arb_valid_quotes"] = valid_quotes + + if not valid_quotes: + valid_quotes = ["USDT", "USDC"] + context.user_data["stat_arb_valid_quotes"] = valid_quotes + + # CONTROLLO PER IL FALLBACK + if len(valid_quotes) < 2: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Error*" + "\n\n" + f"❌ `{asset}` has only {len(valid_quotes)} valid quote(s)\\.\n\n" + r"Please choose a different base asset with at least 2 trading pairs\\.\n\n" + r"*Examples:* BTC, ETH, SOL, KCS", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_base_asset") + ]]), + ) + return + + if not valid_quotes: + valid_quotes = ["USDT", "USDC"] + context.user_data["stat_arb_valid_quotes"] = valid_quotes + + # Vai allo step successivo - costruisci direttamente + context.user_data["stat_arb_wizard_step"] = "quote_asset_1" + + base_asset = config.get("base_asset", "") + valid_quotes = context.user_data.get("stat_arb_valid_quotes", ["USDT", "USDC"]) + + keyboard = [] + row = [] + for quote in valid_quotes[:12]: + row.append(InlineKeyboardButton(quote, callback_data=f"bots:stat_arb_quote_1:{quote}")) + if len(row) == 3: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_base_asset"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 7 if is_perp else 6 + current_step = 3 + + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸͺ™ `{escape_markdown_v2(base_asset)}`" + "\n\n" + f"πŸ“ˆ *First Quote Asset*" + "\n\n" + rf"The quote currency for `{base_asset}-XXX`:" + "\n\n" + rf"*Available quotes with candle data:* {len(valid_quotes)} found" + "\n\n" + r"*Select a button or type the quote asset in chat:*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + # ========== GESTISCI INPUT MANUALE PER QUOTE ASSET 1 ========== + elif step == "quote_asset_1": + quote = user_input.upper().strip() + base_asset = config.get("base_asset", "") + if not base_asset: + # Ricostruisci base_asset + connector = config.get("connector_name", "") + keyboard = [] + common_assets = ["BTC", "ETH", "SOL", "KCS", "BNB", "XRP"] + row = [] + for a in common_assets: + row.append(InlineKeyboardButton(a, callback_data=f"bots:stat_arb_base_asset:{a}")) + if len(row) == 3: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Step 2/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n" + r"πŸͺ™ *Base Asset*" + "\n\n" + r"The common token traded on both pairs \(e\.g\. KCS, BTC, ETH\):" + "\n\n" + r"*Select a button or type the asset name in chat:*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + # Costruisci la coppia + pair = f"{base_asset}-{quote}" + + # Validazione della coppia sull'exchange + connector = config.get("connector_name", "") + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = await validate_trading_pair( + context.user_data, client, connector, pair + ) + + if not is_valid: + keyboard = [] + for sugg in suggestions[:4]: + keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:stat_arb_quote_1:{sugg}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_base_asset")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Step 3*" + "\n\n" + f"❌ `{pair}` not found on `{connector}`\\.\n\n" + r"*Did you mean?*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + if correct_pair: + pair = correct_pair + + config["first_quote_asset"] = quote + config["trading_pair_dominant"] = pair + set_controller_config(context, config) + context.user_data["stat_arb_wizard_step"] = "quote_asset_2" + + # Ricostruisci quote_asset_2 + first_quote = config.get("first_quote_asset", "") + valid_quotes = context.user_data.get("stat_arb_valid_quotes", ["USDT", "USDC"]) + remaining_quotes = [q for q in valid_quotes if q != first_quote] + + keyboard = [] + row = [] + for q in remaining_quotes[:12]: + row.append(InlineKeyboardButton(q, callback_data=f"bots:stat_arb_quote_2:{q}")) + if len(row) == 3: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_quote_1"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 7 if is_perp else 6 + current_step = 4 + + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸͺ™ `{escape_markdown_v2(base_asset)}`" + "\n\n" + rf"*First quote selected:* `{first_quote}`" + "\n\n" + f"πŸ“‰ *Second Quote Asset*" + "\n\n" + rf"The quote currency for `{base_asset}-XXX` \(different from {first_quote}\):" + "\n\n" + rf"*Available quotes:* {len(remaining_quotes)} found" + "\n\n" + r"*Select a button or type the quote asset in chat:*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + # ========== GESTISCI INPUT MANUALE PER QUOTE ASSET 2 ========== + elif step == "quote_asset_2": + quote = user_input.upper().strip() + base_asset = config.get("base_asset", "") + first_quote = config.get("first_quote_asset", "") + + if not base_asset: + # Ricostruisci base_asset (come sopra) + connector = config.get("connector_name", "") + keyboard = [] + common_assets = ["BTC", "ETH", "SOL", "KCS", "BNB", "XRP"] + row = [] + for a in common_assets: + row.append(InlineKeyboardButton(a, callback_data=f"bots:stat_arb_base_asset:{a}")) + if len(row) == 3: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Step 2/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n" + r"πŸͺ™ *Base Asset*" + "\n\n" + r"The common token traded on both pairs \(e\.g\. KCS, BTC, ETH\):" + "\n\n" + r"*Select a button or type the asset name in chat:*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + # Verifica che non sia uguale alla prima quote + if quote == first_quote: + # Ricostruisci quote_asset_2 + valid_quotes = context.user_data.get("stat_arb_valid_quotes", ["USDT", "USDC"]) + remaining_quotes = [q for q in valid_quotes if q != first_quote] + connector = config.get("connector_name", "") + keyboard = [] + row = [] + for q in remaining_quotes[:12]: + row.append(InlineKeyboardButton(q, callback_data=f"bots:stat_arb_quote_2:{q}")) + if len(row) == 3: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_quote_1"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + total_steps = 7 if is_perp else 6 + current_step = 4 + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸͺ™ `{escape_markdown_v2(base_asset)}`" + "\n\n" + rf"*First quote selected:* `{first_quote}`" + "\n\n" + f"πŸ“‰ *Second Quote Asset*" + "\n\n" + rf"The quote currency for `{base_asset}-XXX` \(different from {first_quote}\):" + "\n\n" + rf"*Available quotes:* {len(remaining_quotes)} found" + "\n\n" + r"*Select a button or type the quote asset in chat:*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + # Costruisci la coppia + pair = f"{base_asset}-{quote}" + + # Validazione della coppia sull'exchange + connector = config.get("connector_name", "") + client, _ = await get_bots_client(chat_id, context.user_data) + is_valid, error_msg, suggestions, correct_pair = await validate_trading_pair( + context.user_data, client, connector, pair + ) + + if not is_valid: + keyboard = [] + for sugg in suggestions[:4]: + keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:stat_arb_quote_2:{sugg}")]) + keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_quote_1")]) + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Step 4*" + "\n\n" + f"❌ `{pair}` not found on `{connector}`\\.\n\n" + r"*Did you mean?*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + if correct_pair: + pair = correct_pair + + config["second_quote_asset"] = quote + config["trading_pair_hedge"] = pair + set_controller_config(context, config) + + # Vai a leverage o amount + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + if is_perp: + context.user_data["stat_arb_wizard_step"] = "leverage" + + # Ricostruisci leverage step + dom_pair = config.get("trading_pair_dominant", "") + hedge_pair = config.get("trading_pair_hedge", "") + base_asset = config.get("base_asset", "") + + keyboard = [ + [ + InlineKeyboardButton("1x", callback_data="bots:stat_arb_leverage:1"), + InlineKeyboardButton("5x", callback_data="bots:stat_arb_leverage:5"), + InlineKeyboardButton("10x", callback_data="bots:stat_arb_leverage:10"), + ], + [ + InlineKeyboardButton("20x", callback_data="bots:stat_arb_leverage:20"), + InlineKeyboardButton("50x", callback_data="bots:stat_arb_leverage:50"), + InlineKeyboardButton("75x", callback_data="bots:stat_arb_leverage:75"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_quote_2"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + current_step = 5 + total_steps = 7 if is_perp else 6 + + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸͺ™ `{escape_markdown_v2(base_asset)}`" + "\n\n" + f"πŸ“ˆ `{escape_markdown_v2(dom_pair)}` vs πŸ“‰ `{escape_markdown_v2(hedge_pair)}`" + "\n\n" + r"⚑ *Select Leverage*" + "\n" + r"_Or type a value \(e\.g\. 20\)_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + else: + config["leverage"] = 1 + config["position_mode"] = "HEDGE" + set_controller_config(context, config) + context.user_data["stat_arb_wizard_step"] = "total_amount_quote" + + # Ricostruisci amount step + dom_pair = config.get("trading_pair_dominant", "") + hedge_pair = config.get("trading_pair_hedge", "") + base_asset = config.get("base_asset", "") + leverage = config.get("leverage", 1) + + back_callback = "bots:stat_arb_back_to_quote_2" + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:stat_arb_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:stat_arb_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:stat_arb_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:stat_arb_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:stat_arb_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:stat_arb_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data=back_callback), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + current_step = 5 if is_perp else 5 + total_steps = 7 if is_perp else 6 + + header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| πŸͺ™ `{escape_markdown_v2(base_asset)}`" + + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n" + + header_info + "\n\n" + f"πŸ“ˆ `{escape_markdown_v2(dom_pair)}` vs πŸ“‰ `{escape_markdown_v2(hedge_pair)}`" + "\n\n" + r"πŸ’° *Total Amount \(Quote\)*" + "\n" + r"_Select or type an amount \(will be split between legs via hedge ratio\):_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + # ========== GESTISCI INPUT PER LEVERAGE ========== + elif step == "leverage": + try: + clean_input = user_input.strip().lower().replace("x", "") + val = int(float(clean_input)) + if val < 1: + raise ValueError("Leverage must be at least 1") + config["leverage"] = val + config["position_mode"] = "HEDGE" + set_controller_config(context, config) + context.user_data["stat_arb_wizard_step"] = "total_amount_quote" + + # Ricostruisci amount step + connector = config.get("connector_name", "") + base_asset = config.get("base_asset", "") + dom_pair = config.get("trading_pair_dominant", "") + hedge_pair = config.get("trading_pair_hedge", "") + leverage = config.get("leverage", 1) + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + back_callback = "bots:stat_arb_back_to_leverage" if is_perp else "bots:stat_arb_back_to_quote_2" + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:stat_arb_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:stat_arb_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:stat_arb_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:stat_arb_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:stat_arb_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:stat_arb_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data=back_callback), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + current_step = 6 if is_perp else 5 + total_steps = 7 if is_perp else 6 + + header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| πŸͺ™ `{escape_markdown_v2(base_asset)}`" + if is_perp: + header_info += f" \\| ⚑ `{leverage}x`" + + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n" + + header_info + "\n\n" + f"πŸ“ˆ `{escape_markdown_v2(dom_pair)}` vs πŸ“‰ `{escape_markdown_v2(hedge_pair)}`" + "\n\n" + r"πŸ’° *Total Amount \(Quote\)*" + "\n" + r"_Select or type an amount \(will be split between legs via hedge ratio\):_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except ValueError: + # Ricostruisci leverage step + connector = config.get("connector_name", "") + base_asset = config.get("base_asset", "") + dom_pair = config.get("trading_pair_dominant", "") + hedge_pair = config.get("trading_pair_hedge", "") + + keyboard = [ + [ + InlineKeyboardButton("1x", callback_data="bots:stat_arb_leverage:1"), + InlineKeyboardButton("5x", callback_data="bots:stat_arb_leverage:5"), + InlineKeyboardButton("10x", callback_data="bots:stat_arb_leverage:10"), + ], + [ + InlineKeyboardButton("20x", callback_data="bots:stat_arb_leverage:20"), + InlineKeyboardButton("50x", callback_data="bots:stat_arb_leverage:50"), + InlineKeyboardButton("75x", callback_data="bots:stat_arb_leverage:75"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_quote_2"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + current_step = 5 if is_perp else 5 + total_steps = 7 if is_perp else 6 + + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸͺ™ `{escape_markdown_v2(base_asset)}`" + "\n\n" + f"πŸ“ˆ `{escape_markdown_v2(dom_pair)}` vs πŸ“‰ `{escape_markdown_v2(hedge_pair)}`" + "\n\n" + r"⚑ *Select Leverage*" + "\n" + r"_Or type a value \(e\.g\. 20\)_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + # ========== GESTISCI INPUT PER TOTAL_AMOUNT_QUOTE ========== + elif step == "total_amount_quote": + try: + clean_input = user_input.strip().replace("$", "").replace(",", "").replace(" ", "") + amount = float(clean_input) + if amount <= 0: + raise ValueError("Amount must be positive") + + config["total_amount_quote"] = amount + set_controller_config(context, config) + context.user_data["stat_arb_wizard_step"] = "final" + + dom_pair = config.get("trading_pair_dominant", "") + if message_id: + try: + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- New Config*" + "\n\n" + rf"⏳ Loading cointegration analysis for `{escape_markdown_v2(dom_pair)}`\.\.\.", + parse_mode="MarkdownV2", + ) + except Exception: + pass + + await _stat_arb_show_final_step(update, context) + + except ValueError: + # Ricostruisci amount step + connector = config.get("connector_name", "") + base_asset = config.get("base_asset", "") + dom_pair = config.get("trading_pair_dominant", "") + hedge_pair = config.get("trading_pair_hedge", "") + leverage = config.get("leverage", 1) + + is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower() + back_callback = "bots:stat_arb_back_to_leverage" if is_perp else "bots:stat_arb_back_to_quote_2" + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:stat_arb_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:stat_arb_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:stat_arb_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:stat_arb_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:stat_arb_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:stat_arb_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data=back_callback), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + current_step = 6 if is_perp else 5 + total_steps = 7 if is_perp else 6 + + header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| πŸͺ™ `{escape_markdown_v2(base_asset)}`" + if is_perp: + header_info += f" \\| ⚑ `{leverage}x`" + + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n" + + header_info + "\n\n" + f"πŸ“ˆ `{escape_markdown_v2(dom_pair)}` vs πŸ“‰ `{escape_markdown_v2(hedge_pair)}`" + "\n\n" + r"πŸ’° *Total Amount \(Quote\)*" + "\n" + r"_Select or type an amount \(will be split between legs via hedge ratio\):_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + # ========== GESTISCI INPUT PER FINAL (EDIT CAMPI) ========== + elif step == "final": + if "=" in user_input: + for line in user_input.strip().split("\n"): + if "=" not in line: + continue + field, value = line.split("=", 1) + field = field.strip().lower() + value = value.strip() + try: + if field in ("total_amount_quote", "entry_threshold", "take_profit", "tp_global", "sl_global", + "quoter_spread", "max_position_deviation", "pos_hedge_ratio", + "max_dynamic_hedge_ratio", "min_dynamic_hedge_ratio", "min_r_squared", + "adf_pvalue_threshold"): + config[field] = float(value) + elif field in ("leverage", "lookback_period", "min_amount_quote", "quoter_cooldown", + "quoter_refresh", "max_orders_placed_per_side", "max_orders_filled_per_side"): + config[field] = int(float(value)) + elif field == "use_dynamic_hedge_ratio": + config[field] = value.lower() in ("true", "yes", "1") + elif field == "interval": + config["interval"] = value + context.user_data.pop("stat_arb_candles_dom", None) + context.user_data.pop("stat_arb_candles_hedge", None) + context.user_data["stat_arb_chart_interval"] = value + else: + config[field] = value + except Exception: + pass + set_controller_config(context, config) + await _stat_arb_show_final_step(update, context) + + except Exception as e: + logger.error(f"Stat Arb wizard input error: {e}", exc_info=True) + +# ============================================ +# LM MULTI PAIR DEX WIZARD (lm_multi_pair_dex) +# ============================================ +# Steps: connector β†’ markets β†’ token β†’ amount β†’ allocation β†’ final +# Prefisso handler: lmp_ + +async def show_new_lm_multi_pair_dex_form(update, context) -> None: + """Start the LM Multi Pair DEX wizard - Step 1: Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + # Clear cached data + for key in ["lmp_current_prices", "lmp_candles", "lmp_liquidity_analysis"]: + context.user_data.pop(key, None) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + 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}") + + init_new_controller_config(context, "lm_multi_pair_dex") + context.user_data["bots_state"] = "lmp_wizard" + context.user_data["lmp_wizard_step"] = "connector_name" + context.user_data["lmp_wizard_message_id"] = query.message.message_id + context.user_data["lmp_wizard_chat_id"] = query.message.chat_id + + await _lmp_show_connector_step(update, context) + + +async def _lmp_show_connector_step(update, context) -> None: + """LMP Step 1: Select DEX Connector""" + query = update.callback_query + chat_id = update.effective_chat.id + + # Available DEX connectors + dex_connectors = [ + ("xrpl", "πŸ”΅ XRPL DEX", "Fee ~0.000012 XRP, self-custody, 3-5s latency"), + ("hyperliquid", "🟣 Hyperliquid", "Maker rebate -0.01%, 0.2ms latency"), + ] + + keyboard = [] + for conn_id, label, desc in dex_connectors: + keyboard.append([ + InlineKeyboardButton( + f"{label}", + callback_data=f"bots:lmp_connector:{conn_id}" + ) + ]) + + keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")]) + + await query.message.edit_text( + r"*πŸ“Š LM Multi Pair DEX \- New Config*" + "\n\n" + r"Market making multi\-coppia ottimizzato per DEX con order book\." + "\n\n" + r"─────────────────────────" + "\n\n" + r"*Step 1: Select DEX*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_lmp_wizard_connector(update, context, connector: str) -> None: + """Handle connector selection""" + config = get_controller_config(context) + config["connector_name"] = connector + + if connector == "xrpl": + config["token"] = "XRP" + config["buy_spreads"] = [0.005, 0.01, 0.02] + config["sell_spreads"] = [0.005, 0.01, 0.02] + config["order_refresh_time"] = 60 + config["cooldown_time"] = 30 + config["order_refresh_tolerance_pct"] = 0.01 + default_markets = ["XRP-RLUSD", "BTC-XRP", "ETH-RLUSD"] + else: # hyperliquid + config["token"] = "USDC" + config["buy_spreads"] = [0.002, 0.004, 0.006] + config["sell_spreads"] = [0.002, 0.004, 0.006] + config["order_refresh_time"] = 30 + config["cooldown_time"] = 15 + config["order_refresh_tolerance_pct"] = 0.005 + default_markets = ["SOL-USDC", "ETH-USDC", "BTC-USDC"] + + config["markets"] = default_markets + set_controller_config(context, config) + context.user_data["lmp_wizard_step"] = "markets" + await _lmp_show_markets_step(update, context) + + +async def _lmp_show_markets_step(update, context) -> None: + """LMP Step 2: Configure Trading Pairs""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + token = config.get("token", "USDC") + current_markets = config.get("markets", []) + + context.user_data["bots_state"] = "lmp_wizard_input" + context.user_data["lmp_wizard_step"] = "markets" + + # Suggest pairs based on connector + if connector == "xrpl": + suggested = ["XRP-RLUSD", "BTC-XRP", "ETH-RLUSD", "XRP-USD"] + else: + suggested = ["SOL-USDC", "ETH-USDC", "BTC-USDC", "ARB-USDC", "OP-USDC"] + + keyboard = [] + row = [] + for pair in suggested: + is_selected = pair in current_markets + checkbox = "βœ… " if is_selected else "βž• " + row.append(InlineKeyboardButton( + f"{checkbox}{pair}", + callback_data=f"bots:lmp_toggle_pair:{pair}" + )) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + # Next button + if current_markets: + keyboard.append([ + InlineKeyboardButton("βœ… Next", callback_data="bots:lmp_next_markets") + ]) + + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:lmp_back_to_connector"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + escaped_connector = escape_markdown_v2(connector) + escaped_token = escape_markdown_v2(token) + markets_str = ", ".join(current_markets) if current_markets else "None" + + await query.message.edit_text( + rf"*πŸ“Š LM Multi Pair DEX \- Step 2/6*" + "\n\n" + f"🏦 `{escaped_connector}` \\| πŸ’° `{escaped_token}`" + "\n\n" + r"πŸ”— *Trading Pairs*" + "\n\n" + f"Selected: `{markets_str}`" + "\n\n" + r"*Tap buttons to add/remove:*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_lmp_toggle_pair(update, context, pair: str) -> None: + """Toggle pair selection""" + config = get_controller_config(context) + markets = config.get("markets", []) + + if pair in markets: + markets.remove(pair) + else: + markets.append(pair) + + config["markets"] = markets + set_controller_config(context, config) + + await _lmp_show_markets_step(update, context) + + +async def handle_lmp_next_markets(update, context) -> None: + """Proceed to token step""" + config = get_controller_config(context) + markets = config.get("markets", []) + + if not markets: + query = update.callback_query + await query.answer("Please select at least one trading pair", show_alert=True) + return + + context.user_data["lmp_wizard_step"] = "token" + await _lmp_show_token_step(update, context) + + +async def _lmp_show_token_step(update, context) -> None: + """LMP Step 3: Select/Confirm Unified Token""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + current_token = config.get("token", "") + + context.user_data["bots_state"] = "lmp_wizard_input" + context.user_data["lmp_wizard_step"] = "token" + + # Suggest token based on connector + if connector == "xrpl": + suggestions = ["XRP", "RLUSD"] + hint = "XRP for lower fees, RLUSD for stablecoin pairs" + else: + suggestions = ["USDC"] + hint = "USDC recommended for best fee structure" + + keyboard = [] + for token in suggestions: + marker = "βœ“ " if token == current_token else "" + keyboard.append([ + InlineKeyboardButton( + f"{marker}{token}", + callback_data=f"bots:lmp_token:{token}" + ) + ]) + + keyboard.append([ + InlineKeyboardButton("⬅️ Back", callback_data="bots:lmp_back_to_markets"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ]) + + escaped_connector = escape_markdown_v2(connector) + escaped_current = escape_markdown_v2(current_token) if current_token else "Not set" + + await query.message.edit_text( + rf"*πŸ“Š LM Multi Pair DEX \- Step 3/6*" + "\n\n" + f"🏦 `{escaped_connector}`" + "\n\n" + r"πŸ’° *Unified Token*" + "\n\n" + f"Current: `{escaped_current}`" + "\n\n" + f"*{escape_markdown_v2(hint)}*" + "\n\n" + r"*Select a token:*", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_lmp_token(update, context, token: str) -> None: + """Handle token selection""" + config = get_controller_config(context) + config["token"] = token + set_controller_config(context, config) + context.user_data["lmp_wizard_step"] = "allocation" + await _lmp_show_allocation_step(update, context) + + +async def _lmp_show_allocation_step(update, context) -> None: + """LMP Step 4: Portfolio Allocation %""" + query = update.callback_query + config = get_controller_config(context) + connector = config.get("connector_name", "") + current_allocation = config.get("portfolio_allocation", 0.10) * 100 + + context.user_data["bots_state"] = "lmp_wizard_input" + context.user_data["lmp_wizard_step"] = "allocation" + + keyboard = [ + [ + InlineKeyboardButton("5%", callback_data="bots:lmp_allocation:0.05"), + InlineKeyboardButton("10%", callback_data="bots:lmp_allocation:0.10"), + InlineKeyboardButton("15%", callback_data="bots:lmp_allocation:0.15"), + ], + [ + InlineKeyboardButton("20%", callback_data="bots:lmp_allocation:0.20"), + InlineKeyboardButton("25%", callback_data="bots:lmp_allocation:0.25"), + InlineKeyboardButton("30%", callback_data="bots:lmp_allocation:0.30"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:lmp_back_to_token"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + escaped_connector = escape_markdown_v2(connector) + escaped_token = escape_markdown_v2(config.get("token", "")) + + await query.message.edit_text( + rf"*πŸ“Š LM Multi Pair DEX \- Step 4/6*" + "\n\n" + f"🏦 `{escaped_connector}` \\| πŸ’° `{escaped_token}`" + "\n\n" + r"πŸ’° *Portfolio Allocation*" + "\n\n" + f"Current: `{current_allocation:.0f}%` of capital" + "\n\n" + r"_Or type a custom value \(e\.g\. 12%\)_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_lmp_allocation(update, context, allocation: float) -> None: + """Handle allocation selection""" + config = get_controller_config(context) + config["portfolio_allocation"] = allocation + set_controller_config(context, config) + context.user_data["lmp_wizard_step"] = "total_amount_quote" + await _lmp_show_amount_step(update, context) + + +async def _lmp_show_amount_step(update, context) -> None: + """LMP Step 5: Total Amount""" + query = update.callback_query + chat_id = update.effective_chat.id + config = get_controller_config(context) + connector = config.get("connector_name", "") + token = config.get("token", "") + allocation = config.get("portfolio_allocation", 0.10) * 100 + + context.user_data["bots_state"] = "lmp_wizard_input" + context.user_data["lmp_wizard_step"] = "total_amount_quote" + + # Fetch balance + balance_text = "" + try: + client, _ = await get_bots_client(chat_id, context.user_data) + balances = await get_cex_balances( + context.user_data, client, "master_account", ttl=30 + ) + + # Flexible matching come in Grid Strike + 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", "") + if bal_lower == connector_lower or bal_base == connector_base: + connector_balances = bal_list + break + + if connector_balances: + relevant_balances = [] + quote = pair.split("-")[1] if "-" in pair else "USDT" + for bal in connector_balances: + token = bal.get("token", bal.get("asset", "")) + available = bal.get("units", bal.get("available_balance", bal.get("free", 0))) + if token and token.upper() == quote.upper(): + try: + available_float = float(available) + if available_float > 0: + balance_text = f"\n\nπŸ’° Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`" + break + except (ValueError, TypeError): + continue + except Exception as e: + logger.warning(f"Could not fetch balances for Liquidity Multi Pair amount step: {e}") + + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:lmp_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:lmp_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:lmp_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:lmp_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:lmp_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:lmp_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:lmp_back_to_allocation"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + escaped_connector = escape_markdown_v2(connector) + escaped_token = escape_markdown_v2(token) + + await query.message.edit_text( + rf"*πŸ“Š LM Multi Pair DEX \- Step 5/6*" + "\n\n" + f"🏦 `{escaped_connector}` \\| πŸ’° `{escaped_token}`" + "\n\n" + f"πŸ’° Portfolio Allocation: `{allocation:.0f}%`" + balance_text + "\n\n" + r"πŸ’° *Total Capital*" + "\n" + r"_Select or type an amount:_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def handle_lmp_wizard_amount(update, context, amount: float) -> None: + """Handle amount selection""" + query = update.callback_query + config = get_controller_config(context) + config["total_amount_quote"] = amount + set_controller_config(context, config) + + await query.message.edit_text( + r"*πŸ“Š LM Multi Pair DEX \- New Config*" + "\n\n" + r"⏳ *Generating configuration\.\.\.*" + "\n\n" + r"_Analyzing liquidity and building grid\.\.\._", + parse_mode="MarkdownV2", + ) + + context.user_data["lmp_wizard_step"] = "final" + await _lmp_show_final_step(update, context) + + +async def _lmp_show_final_step(update, context, interval: str = None) -> None: + """LMP Final Step: Show config summary (MarkdownV2)""" + query = update.callback_query + msg = query.message if query else update.message + chat_id = update.effective_chat.id + config = get_controller_config(context) + + connector = config.get("connector_name", "") + markets = config.get("markets", []) + token = config.get("token", "") + total_amount = config.get("total_amount_quote", 1000) + allocation = config.get("portfolio_allocation", 0.10) + buy_spreads = config.get("buy_spreads", [0.005, 0.01, 0.02]) + sell_spreads = config.get("sell_spreads", [0.005, 0.01, 0.02]) + order_refresh_time = config.get("order_refresh_time", 45) + cooldown_time = config.get("cooldown_time", 20) + tolerance = config.get("order_refresh_tolerance_pct", 0.01) + target_base = config.get("target_base_pct", 0.5) * 100 + min_base = config.get("min_base_pct", 0.3) * 100 + max_base = config.get("max_base_pct", 0.7) * 100 + max_skew = config.get("max_skew", 0.2) * 100 + + if not config.get("id"): + existing_configs = context.user_data.get("controller_configs_list", []) + config["id"] = generate_id(config, existing_configs) + + is_hyperliquid = connector == "hyperliquid" + fee_note = "πŸ’° Maker rebate: -0.01% (TI PAGANO)" if is_hyperliquid else "πŸ’° Fee per ordine: ~0.000012 XRP (quasi zero)" + + context.user_data["bots_state"] = "lmp_wizard_input" + context.user_data["lmp_wizard_step"] = "final" + + keyboard = [ + [InlineKeyboardButton("πŸ’Ύ Save Config", callback_data="bots:lmp_save")], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:lmp_back_to_amount"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + + # Escapiamo i campi dinamici per MarkdownV2 + escaped_connector = escape_markdown_v2(connector) + escaped_token = escape_markdown_v2(token) + escaped_markets = escape_markdown_v2(", ".join(markets)) + escaped_fee_note = escape_markdown_v2(fee_note) + # Il config_block Γ¨ in un blocco di codice, non va escapato + config_block = ( + f"id: {config.get('id', '')}\n" + f"connector_name: {connector}\n" + f"markets: {markets}\n" + f"token: {token}\n" + f"total_amount_quote: {total_amount:.0f}\n" + f"portfolio_allocation: {allocation}\n" + f"buy_spreads: {buy_spreads}\n" + f"sell_spreads: {sell_spreads}\n" + f"order_refresh_time: {order_refresh_time}\n" + f"cooldown_time: {cooldown_time}\n" + f"order_refresh_tolerance_pct: {tolerance}\n" + f"target_base_pct: {target_base/100:.2f}\n" + f"min_base_pct: {min_base/100:.2f}\n" + f"max_base_pct: {max_base/100:.2f}\n" + f"max_skew: {max_skew/100:.2f}" + ) + + # Costruiamo il testo con MarkdownV2, escapando i caratteri speciali delle parti statiche + # Nota: i punti statici vanno escapati con \., i due punti con \:, ecc. + # Ma possiamo usare escape_markdown_v2 sull'intero testo esclusi i blocchi di codice. + # Per semplicitΓ , costruiamo il testo con le parti giΓ  escaped. + config_text = ( + f"*πŸ“Š LM Multi Pair DEX \\- Step 6/6 \\(Final\\)*\n\n" + f"🏦 `{escaped_connector}` \\| πŸ’° `{escaped_token}`\n\n" + f"{escaped_fee_note}\n\n" + f"πŸ”— *Markets:* `{escaped_markets}`\n\n" + f"```\n{config_block}\n```\n\n" + r"_Edit\: `field\=value`_" + ) + + try: + await msg.edit_text( + text=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["lmp_wizard_message_id"] = msg.message_id + except Exception: + new_msg = await context.bot.send_message( + chat_id=chat_id, + text=config_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + context.user_data["lmp_wizard_message_id"] = new_msg.message_id +async def handle_lmp_save(update, context) -> None: + """Save LM Multi Pair DEX configuration""" + query = update.callback_query + config = get_controller_config(context) + + config_id = config.get("id", "") + chat_id = query.message.chat_id + + try: + await query.message.delete() + except Exception: + pass + + status_msg = await context.bot.send_message( + chat_id=chat_id, + text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.", + parse_mode="MarkdownV2", + ) + + try: + client, _ = await get_bots_client(chat_id, context.user_data) + await client.controllers.create_or_update_controller_config(config_id, config) + + for key in ["lmp_wizard_step", "lmp_wizard_message_id", "lmp_wizard_chat_id", + "lmp_current_prices", "lmp_candles", "lmp_liquidity_analysis"]: + context.user_data.pop(key, None) + context.user_data.pop("bots_state", None) + + keyboard = [ + [InlineKeyboardButton("Create Another", callback_data="bots:new_lm_multi_pair_dex")], + [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + "*Config Saved\\!*\n\n" + "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + except Exception as e: + logger.error(f"LMP save error: {e}", exc_info=True) + keyboard = [ + [InlineKeyboardButton("Try Again", callback_data="bots:lmp_save")], + [InlineKeyboardButton("Back", callback_data="bots:controller_configs")], + ] + await status_msg.edit_text( + format_error_message(f"Failed to save: {str(e)}"), + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +# Back handlers +async def handle_lmp_back_to_connector(update, context) -> None: + context.user_data["lmp_wizard_step"] = "connector_name" + await _lmp_show_connector_step(update, context) + + +async def handle_lmp_back_to_markets(update, context) -> None: + context.user_data["lmp_wizard_step"] = "markets" + await _lmp_show_markets_step(update, context) + + +async def handle_lmp_back_to_token(update, context) -> None: + context.user_data["lmp_wizard_step"] = "token" + await _lmp_show_token_step(update, context) + + +async def handle_lmp_back_to_allocation(update, context) -> None: + context.user_data["lmp_wizard_step"] = "allocation" + await _lmp_show_allocation_step(update, context) + + +async def handle_lmp_back_to_amount(update, context) -> None: + context.user_data["lmp_wizard_step"] = "total_amount_quote" + await _lmp_show_amount_step(update, context) + + +async def process_lmp_wizard_input(update, context, user_input: str) -> None: + """Process text input during LM Multi Pair DEX wizard""" + step = context.user_data.get("lmp_wizard_step") + chat_id = update.effective_chat.id + config = get_controller_config(context) + message_id = context.user_data.get("lmp_wizard_message_id") + wizard_chat_id = context.user_data.get("lmp_wizard_chat_id", chat_id) + + try: + await update.message.delete() + except Exception: + pass + + if step == "allocation": + try: + clean_input = user_input.strip().replace("%", "") + val = float(clean_input) + if val > 1: # User entered percentage like "10" + val = val / 100 + if val <= 0 or val > 1: + raise ValueError("Allocation must be between 0 and 1") + config["portfolio_allocation"] = val + set_controller_config(context, config) + context.user_data["lmp_wizard_step"] = "total_amount_quote" + await _lmp_show_amount_step(update, context) + except ValueError: + connector = config.get("connector_name", "") + token = config.get("token", "") + keyboard = [ + [ + InlineKeyboardButton("5%", callback_data="bots:lmp_allocation:0.05"), + InlineKeyboardButton("10%", callback_data="bots:lmp_allocation:0.10"), + InlineKeyboardButton("15%", callback_data="bots:lmp_allocation:0.15"), + ], + [ + InlineKeyboardButton("20%", callback_data="bots:lmp_allocation:0.20"), + InlineKeyboardButton("25%", callback_data="bots:lmp_allocation:0.25"), + InlineKeyboardButton("30%", callback_data="bots:lmp_allocation:0.30"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:lmp_back_to_token"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š LM Multi Pair DEX \- Step 4/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ’° `{escape_markdown_v2(token)}`" + "\n\n" + r"πŸ’° *Portfolio Allocation*" + "\n\n" + r"⚠️ *Invalid value\. Enter a number between 1 and 30 \(e\.g\. 10\)*" + "\n\n" + r"_Select or type a value:_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + elif step == "total_amount_quote": + try: + amount = float(user_input.strip().replace("$", "").replace(",", "")) + config["total_amount_quote"] = amount + set_controller_config(context, config) + context.user_data["lmp_wizard_step"] = "final" + await _lmp_show_final_step(update, context) + except ValueError: + connector = config.get("connector_name", "") + token = config.get("token", "") + allocation = config.get("portfolio_allocation", 0.10) * 100 + keyboard = [ + [ + InlineKeyboardButton("$100", callback_data="bots:lmp_amount:100"), + InlineKeyboardButton("$500", callback_data="bots:lmp_amount:500"), + InlineKeyboardButton("$1000", callback_data="bots:lmp_amount:1000"), + ], + [ + InlineKeyboardButton("$2000", callback_data="bots:lmp_amount:2000"), + InlineKeyboardButton("$5000", callback_data="bots:lmp_amount:5000"), + InlineKeyboardButton("$10000", callback_data="bots:lmp_amount:10000"), + ], + [ + InlineKeyboardButton("⬅️ Back", callback_data="bots:lmp_back_to_allocation"), + InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"), + ], + ] + await context.bot.edit_message_text( + chat_id=wizard_chat_id, + message_id=message_id, + text=rf"*πŸ“Š LM Multi Pair DEX \- Step 5/6*" + "\n\n" + f"🏦 `{escape_markdown_v2(connector)}` \\| πŸ’° `{escape_markdown_v2(token)}`" + "\n\n" + f"πŸ’° Portfolio Allocation: `{allocation:.0f}%`" + "\n\n" + r"⚠️ *Invalid amount*" + "\n\n" + r"_Select or type an amount:_", + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + elif step == "final": + if "=" in user_input: + # Handle field=value edits + supported_fields = [ + "total_amount_quote", "portfolio_allocation", "order_refresh_time", + "cooldown_time", "order_refresh_tolerance_pct", "target_base_pct", + "min_base_pct", "max_base_pct", "max_skew", "min_liquidity_score" + ] + + for line in user_input.strip().split("\n"): + if "=" not in line: + continue + field, value = line.split("=", 1) + field = field.strip().lower() + value = value.strip() + + if field not in supported_fields: + continue + + try: + if field in ("total_amount_quote", "portfolio_allocation", + "order_refresh_tolerance_pct", "target_base_pct", + "min_base_pct", "max_base_pct", "max_skew", + "min_liquidity_score"): + config[field] = float(value) + elif field in ("order_refresh_time", "cooldown_time"): + config[field] = int(float(value)) + else: + config[field] = value + except Exception: + pass + + set_controller_config(context, config) + await _lmp_show_final_step(update, context) + + +async def handle_lmp_pair_select( + update: Update, context: ContextTypes.DEFAULT_TYPE, trading_pair: str +) -> None: + """Handle selection of a suggested trading pair in LM wizard""" + config = get_controller_config(context) + markets = config.get("markets", []) + if trading_pair not in markets: + markets.append(trading_pair) + config["markets"] = markets + set_controller_config(context, config) + await _lmp_show_markets_step(update, context) # ============================================ # CUSTOM CONFIG UPLOAD From 107f1a6236b91ef46d9c5161f522087e5c51667c Mon Sep 17 00:00:00 2001 From: pierluigipanariello Date: Fri, 19 Jun 2026 13:09:59 +0200 Subject: [PATCH 2/2] Add custom modifications --- frontend/package-lock.json | 1006 +++++----- handlers/bots/__init__.py | 1721 +++++++++++++++-- handlers/bots/controllers/__init__.py | 38 +- .../controllers/anti_folla_v1/__init__.py | 42 + .../controllers/anti_folla_v1/analysis.py | 532 +++++ .../bots/controllers/anti_folla_v1/chart.py | 499 +++++ .../bots/controllers/anti_folla_v1/config.py | 202 ++ .../arbitrage_controller/__init__.py | 88 + .../arbitrage_controller/analysis.py | 121 ++ .../controllers/arbitrage_controller/chart.py | 535 +++++ .../arbitrage_controller/config.py | 302 +++ .../bots/controllers/bollingrid/__init__.py | 46 + handlers/bots/controllers/bollingrid/chart.py | 408 ++++ .../bots/controllers/bollingrid/config.py | 101 + .../controllers/delta_neutral_mm/__init__.py | 46 + .../controllers/delta_neutral_mm/chart.py | 245 +++ .../controllers/delta_neutral_mm/config.py | 365 ++++ handlers/bots/controllers/dman_v3/__init__.py | 42 + handlers/bots/controllers/dman_v3/analysis.py | 315 +++ handlers/bots/controllers/dman_v3/chart.py | 311 +++ handlers/bots/controllers/dman_v3/config.py | 175 ++ .../controllers/funding_rate_arb/__init__.py | 48 + .../controllers/funding_rate_arb/chart.py | 95 + .../controllers/funding_rate_arb/config.py | 292 +++ .../controllers/lm_multi_pair_dex/__init__.py | 75 + .../controllers/lm_multi_pair_dex/analysis.py | 149 ++ .../controllers/lm_multi_pair_dex/chart.py | 148 ++ .../controllers/lm_multi_pair_dex/config.py | 246 +++ .../bots/controllers/macd_bb_v1/__init__.py | 42 + .../bots/controllers/macd_bb_v1/analysis.py | 337 ++++ handlers/bots/controllers/macd_bb_v1/chart.py | 328 ++++ .../bots/controllers/macd_bb_v1/config.py | 363 ++++ .../controllers/multi_grid_strike/__init__.py | 129 ++ .../controllers/multi_grid_strike/chart.py | 123 ++ .../controllers/multi_grid_strike/config.py | 377 ++++ .../multi_grid_strike/grid_analysis.py | 579 ++++++ .../quantum_grid_allocator/__init__.py | 47 + .../quantum_grid_allocator/config.py | 128 ++ .../bots/controllers/stat_arb_v2/__init__.py | 60 + .../bots/controllers/stat_arb_v2/analysis.py | 369 ++++ .../bots/controllers/stat_arb_v2/chart.py | 229 +++ .../bots/controllers/stat_arb_v2/config.py | 335 ++++ .../controllers/supertrend_v1/__init__.py | 42 + .../controllers/supertrend_v1/analysis.py | 327 ++++ .../bots/controllers/supertrend_v1/chart.py | 519 +++++ .../bots/controllers/supertrend_v1/config.py | 132 ++ .../xemm_multiple_levels/__init__.py | 42 + .../controllers/xemm_multiple_levels/chart.py | 41 + .../xemm_multiple_levels/config.py | 203 ++ package-lock.json | 6 + 50 files changed, 12354 insertions(+), 597 deletions(-) create mode 100644 handlers/bots/controllers/anti_folla_v1/__init__.py create mode 100644 handlers/bots/controllers/anti_folla_v1/analysis.py create mode 100644 handlers/bots/controllers/anti_folla_v1/chart.py create mode 100644 handlers/bots/controllers/anti_folla_v1/config.py create mode 100644 handlers/bots/controllers/arbitrage_controller/__init__.py create mode 100644 handlers/bots/controllers/arbitrage_controller/analysis.py create mode 100644 handlers/bots/controllers/arbitrage_controller/chart.py create mode 100644 handlers/bots/controllers/arbitrage_controller/config.py create mode 100644 handlers/bots/controllers/bollingrid/__init__.py create mode 100644 handlers/bots/controllers/bollingrid/chart.py create mode 100644 handlers/bots/controllers/bollingrid/config.py create mode 100644 handlers/bots/controllers/delta_neutral_mm/__init__.py create mode 100644 handlers/bots/controllers/delta_neutral_mm/chart.py create mode 100644 handlers/bots/controllers/delta_neutral_mm/config.py create mode 100644 handlers/bots/controllers/dman_v3/__init__.py create mode 100644 handlers/bots/controllers/dman_v3/analysis.py create mode 100644 handlers/bots/controllers/dman_v3/chart.py create mode 100644 handlers/bots/controllers/dman_v3/config.py create mode 100644 handlers/bots/controllers/funding_rate_arb/__init__.py create mode 100644 handlers/bots/controllers/funding_rate_arb/chart.py create mode 100644 handlers/bots/controllers/funding_rate_arb/config.py create mode 100644 handlers/bots/controllers/lm_multi_pair_dex/__init__.py create mode 100644 handlers/bots/controllers/lm_multi_pair_dex/analysis.py create mode 100644 handlers/bots/controllers/lm_multi_pair_dex/chart.py create mode 100644 handlers/bots/controllers/lm_multi_pair_dex/config.py create mode 100644 handlers/bots/controllers/macd_bb_v1/__init__.py create mode 100644 handlers/bots/controllers/macd_bb_v1/analysis.py create mode 100644 handlers/bots/controllers/macd_bb_v1/chart.py create mode 100644 handlers/bots/controllers/macd_bb_v1/config.py create mode 100644 handlers/bots/controllers/multi_grid_strike/__init__.py create mode 100644 handlers/bots/controllers/multi_grid_strike/chart.py create mode 100644 handlers/bots/controllers/multi_grid_strike/config.py create mode 100644 handlers/bots/controllers/multi_grid_strike/grid_analysis.py create mode 100644 handlers/bots/controllers/quantum_grid_allocator/__init__.py create mode 100644 handlers/bots/controllers/quantum_grid_allocator/config.py create mode 100644 handlers/bots/controllers/stat_arb_v2/__init__.py create mode 100644 handlers/bots/controllers/stat_arb_v2/analysis.py create mode 100644 handlers/bots/controllers/stat_arb_v2/chart.py create mode 100644 handlers/bots/controllers/stat_arb_v2/config.py create mode 100644 handlers/bots/controllers/supertrend_v1/__init__.py create mode 100644 handlers/bots/controllers/supertrend_v1/analysis.py create mode 100644 handlers/bots/controllers/supertrend_v1/chart.py create mode 100644 handlers/bots/controllers/supertrend_v1/config.py create mode 100644 handlers/bots/controllers/xemm_multiple_levels/__init__.py create mode 100644 handlers/bots/controllers/xemm_multiple_levels/chart.py create mode 100644 handlers/bots/controllers/xemm_multiple_levels/config.py create mode 100644 package-lock.json diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92be2540..5f9cebbf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -49,13 +49,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -64,9 +64,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -74,21 +74,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -105,14 +105,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -122,14 +122,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -139,9 +139,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -149,29 +149,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -181,9 +181,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -191,9 +191,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -201,9 +201,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -211,27 +211,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -241,33 +241,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -275,23 +275,23 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@codemirror/autocomplete": { - "version": "6.20.1", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", - "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -355,20 +355,20 @@ } }, "node_modules/@codemirror/lint": { - "version": "6.9.5", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", - "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.35.0", + "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "node_modules/@codemirror/search": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", - "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -398,9 +398,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.41.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz", - "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==", + "version": "6.43.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz", + "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.6.0", @@ -601,29 +601,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -727,9 +741,9 @@ } }, "node_modules/@lezer/python": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", - "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -755,14 +769,14 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", @@ -813,9 +827,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -859,9 +873,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -876,9 +890,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -893,9 +907,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -910,9 +924,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -927,9 +941,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -944,13 +958,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -961,13 +978,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -978,13 +998,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -995,13 +1018,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1012,13 +1038,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1029,13 +1058,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1046,9 +1078,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -1063,9 +1095,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -1082,9 +1114,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -1099,9 +1131,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -1116,9 +1148,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", - "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -1171,49 +1203,49 @@ "license": "MIT" }, "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" + "tailwindcss": "4.3.1" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", "cpu": [ "arm64" ], @@ -1228,9 +1260,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", "cpu": [ "arm64" ], @@ -1245,9 +1277,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", "cpu": [ "x64" ], @@ -1262,9 +1294,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", "cpu": [ "x64" ], @@ -1279,9 +1311,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", "cpu": [ "arm" ], @@ -1296,13 +1328,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1313,13 +1348,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1330,13 +1368,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1347,13 +1388,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1364,9 +1408,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1382,11 +1426,11 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { @@ -1394,9 +1438,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", "cpu": [ "arm64" ], @@ -1411,9 +1455,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", "cpu": [ "x64" ], @@ -1428,24 +1472,24 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", - "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "tailwindcss": "4.2.2" + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@tanstack/query-core": { - "version": "5.95.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz", - "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==", + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", "license": "MIT", "funding": { "type": "github", @@ -1453,12 +1497,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.95.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", - "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.95.2" + "@tanstack/query-core": "5.101.0" }, "funding": { "type": "github", @@ -1469,9 +1513,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -1552,9 +1596,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -1604,19 +1648,19 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.12.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", - "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1645,20 +1689,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", - "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/type-utils": "8.57.2", - "@typescript-eslint/utils": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1668,9 +1712,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.2", + "@typescript-eslint/parser": "^8.61.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -1684,16 +1728,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", - "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3" }, "engines": { @@ -1705,18 +1749,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", - "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.2", - "@typescript-eslint/types": "^8.57.2", + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", "debug": "^4.4.3" }, "engines": { @@ -1727,18 +1771,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", - "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2" + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1749,9 +1793,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", - "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", "dev": true, "license": "MIT", "engines": { @@ -1762,21 +1806,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", - "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1787,13 +1831,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", "dev": true, "license": "MIT", "engines": { @@ -1805,21 +1849,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", - "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.2", - "@typescript-eslint/tsconfig-utils": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1829,7 +1873,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { @@ -1843,9 +1887,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -1856,13 +1900,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -1872,9 +1916,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -1885,16 +1929,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", - "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2" + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1905,17 +1949,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", - "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/types": "8.61.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1940,19 +1984,19 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" + "@rolldown/pluginutils": "^1.0.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1993,9 +2037,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -2016,9 +2060,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2072,9 +2116,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", - "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2085,9 +2129,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -2096,9 +2140,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -2116,11 +2160,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -2140,9 +2184,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001781", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", - "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -2532,30 +2576,30 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.321", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", - "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" } }, "node_modules/es-toolkit": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", - "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", + "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", "license": "MIT", "workspaces": [ "docs", @@ -2646,9 +2690,9 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", "dependencies": { @@ -2662,13 +2706,13 @@ "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", - "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2926,9 +2970,9 @@ } }, "node_modules/globals": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", - "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -3186,9 +3230,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -3203,9 +3247,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3428,6 +3482,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3449,6 +3506,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3470,6 +3530,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3491,6 +3554,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3547,9 +3613,9 @@ } }, "node_modules/lightweight-charts": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.1.0.tgz", - "integrity": "sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.2.0.tgz", + "integrity": "sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w==", "license": "Apache-2.0", "dependencies": { "fancy-canvas": "2.1.0" @@ -3599,9 +3665,9 @@ } }, "node_modules/lucide-react": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.0.1.tgz", - "integrity": "sha512-lih7tKEczCYOQjVEzpFuxEuNzlwf+1yhvlMlEkGWJM3va8Pugv8bYXc/pRtcjPncaP7k84X0Pt/71ufxvqEPtQ==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -4492,9 +4558,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", "dev": true, "funding": [ { @@ -4518,11 +4584,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/optionator": { "version": "0.9.4", @@ -4689,9 +4758,9 @@ } }, "node_modules/postcss": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", - "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -4709,7 +4778,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4728,9 +4797,9 @@ } }, "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", "license": "MIT", "funding": { "type": "github", @@ -4748,30 +4817,30 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.7" } }, "node_modules/react-is": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", - "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "license": "MIT", "peer": true }, @@ -4826,9 +4895,9 @@ } }, "node_modules/react-router": { - "version": "7.13.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", - "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -4848,12 +4917,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.13.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", - "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", + "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", "license": "MIT", "dependencies": { - "react-router": "7.13.2" + "react-router": "7.18.0" }, "engines": { "node": ">=20.0.0" @@ -4991,14 +5060,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -5007,29 +5076,22 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", - "dev": true, - "license": "MIT" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } }, "node_modules/scheduler": { "version": "0.27.0", @@ -5161,16 +5223,16 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.1.tgz", - "integrity": "sha512-b+u3CEM6FjDHru+nhUSjDofpWSBp2rINziJWgApm72wwGasQ/wKXftZe4tI2Y5HPv6OpzXSZHOFq87H4vfsgsw==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -5188,9 +5250,9 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -5273,16 +5335,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", - "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.2", - "@typescript-eslint/parser": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2" + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5293,13 +5355,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -5521,17 +5583,17 @@ } }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -5547,7 +5609,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5672,9 +5734,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "devOptional": true, "license": "MIT", "funding": { diff --git a/handlers/bots/__init__.py b/handlers/bots/__init__.py index 29f5c5d7..230dd943 100644 --- a/handlers/bots/__init__.py +++ b/handlers/bots/__init__.py @@ -1,143 +1,1616 @@ """ -Controller Registry - -Provides a unified interface for accessing controller type implementations. -Each controller type (grid_strike, pmm, etc.) has its own module with: -- Configuration defaults and field definitions -- Validation logic -- Chart/visualization generation -- ID generation with chronological numbering +Bots module - Bot management and controller configuration + +Supports: +- View active bots status +- Controller configuration (Grid Strike) +- Deploy controllers to backend + +Structure: +- menu.py: Main bots menu and status display +- controllers.py: Controller config management +- _shared.py: Shared utilities and defaults """ -from typing import Dict, List, Optional, Type - -from ._base import BaseController, ControllerField -from .grid_strike import GridStrikeController -from .pmm_mister import PmmMisterController -from .pmm_v1 import PmmV1Controller -from .arbitrage_controller import ArbitrageControllerController -from .dman_v3 import DManV3Controller -from .multi_grid_strike import MultiGridStrikeController -from .xemm_multiple_levels import XEMMMultipleLevelsController -from .macd_bb_v1 import MacdBbV1Controller -from .supertrend_v1 import SuperTrendV1Controller -from .anti_folla_v1 import AntiFollaV1Controller -from .funding_rate_arb import FundingRateArbController -from .delta_neutral_mm import DeltaNeutralMMController -from .bollingrid import BollinGridController -from .quantum_grid_allocator import QuantumGridAllocatorController -from .stat_arb_v2 import StatArbV2Controller -from .lm_multi_pair_dex import LMMultiPairDEXController -# Registry of controller types -_CONTROLLER_REGISTRY: Dict[str, Type[BaseController]] = { - "grid_strike": GridStrikeController, - "pmm_mister": PmmMisterController, - "pmm_v1": PmmV1Controller, - "dman_v3": DManV3Controller, - "arbitrage_controller": ArbitrageControllerController, - "xemm_multiple_levels": XEMMMultipleLevelsController, - "macd_bb_v1": MacdBbV1Controller, - "supertrend_v1": SuperTrendV1Controller, - "anti_folla_v1": AntiFollaV1Controller, - "funding_rate_arb": FundingRateArbController, - "delta_neutral_mm": DeltaNeutralMMController, - "bollingrid": BollinGridController, - "quantum_grid_allocator": QuantumGridAllocatorController, - "stat_arb_v2": LMMultiPairDEXController, - "lm_multi_pair_dex": LMMultiPairDEXController, -} - - -def get_controller(controller_type: str) -> Optional[Type[BaseController]]: - """ - Get a controller class by type. +import logging - Args: - controller_type: The controller type identifier (e.g., "grid_strike") +from telegram import Update +from telegram.ext import CallbackQueryHandler, ContextTypes, MessageHandler, filters - Returns: - Controller class or None if not found - """ - return _CONTROLLER_REGISTRY.get(controller_type) +from handlers import clear_all_input_states +from utils.auth import hummingbot_api_required, restricted +# Archived bots handlers +from .archived import ( + handle_archived_refresh, + handle_generate_report, + show_archived_detail, + show_archived_menu, + show_bot_chart, + show_timeline_chart, +) +from .controller_handlers import ( # Unified configs menu with multi-select; Edit loop; Progressive deploy flow; Streamlined deploy flow; Progressive Grid Strike wizard; PMM Mister wizard; Custom config upload + handle_cfg_branch, + handle_cfg_clear_selection, + handle_cfg_delete_confirm, + handle_cfg_delete_execute, + handle_cfg_deploy, + handle_cfg_edit_cancel, + handle_cfg_edit_field, + handle_cfg_edit_loop, + handle_cfg_edit_next, + handle_cfg_edit_prev, + handle_cfg_edit_save, + handle_cfg_edit_save_all, + handle_cfg_page, + handle_cfg_toggle, + handle_clear_all, + handle_config_file_upload, + handle_configs_page, + handle_cycle_order_type, + handle_deploy_confirm, + handle_deploy_custom_name, + handle_deploy_edit_field, + handle_deploy_prev_field, + handle_deploy_progressive_input, + handle_deploy_set_field, + handle_deploy_skip_field, + handle_deploy_use_default, + handle_edit_config, + handle_execute_deploy, +# ── Grid Strike ─────────────────────────────────────────────────────────── + handle_gs_accept_prices, + handle_gs_back_to_amount, + handle_gs_back_to_connector, + handle_gs_back_to_leverage, + handle_gs_back_to_pair, + handle_gs_back_to_prices, + handle_gs_back_to_side, + handle_gs_edit_act, + handle_gs_edit_batch, + handle_gs_edit_id, + handle_gs_edit_keep, + handle_gs_edit_max_orders, + handle_gs_edit_min_amt, + handle_gs_edit_price, + handle_gs_edit_spread, + handle_gs_edit_tp, + handle_gs_interval_change, + handle_gs_pair_select, + handle_gs_review_back, + handle_gs_save, + handle_gs_wizard_amount, + handle_gs_wizard_connector, + handle_gs_wizard_leverage, + handle_gs_wizard_pair, + handle_gs_wizard_side, + handle_gs_wizard_take_profit, +# ── PMM Mister ──────────────────────────────────────────────────────────── + handle_pmm_adv_setting, + handle_pmm_back, + handle_pmm_edit_advanced, + handle_pmm_edit_field, + handle_pmm_edit_id, + handle_pmm_pair_select, + handle_pmm_review_back, + handle_pmm_save, + handle_pmm_set_field, + handle_pmm_wizard_allocation, + handle_pmm_wizard_amount, + handle_pmm_wizard_connector, + handle_pmm_wizard_leverage, + handle_pmm_wizard_pair, + handle_pmm_wizard_spreads, + handle_pmm_wizard_tp, +# ── PMM V1 ──────────────────────────────────────────────────────────────── + handle_pv1_back, + handle_pv1_pair_select, + handle_pv1_review_back, + handle_pv1_save, + handle_pv1_wizard_amount, + handle_pv1_wizard_connector, + handle_pv1_wizard_pair, + handle_pv1_wizard_spreads, + process_pv1_wizard_input, + show_new_pmm_v1_form, +# ── Multi Grid Strike ───────────────────────────────────────────────────── + show_new_multi_grid_strike_form, + handle_mgs_wizard_connector, + handle_mgs_wizard_pair, + handle_mgs_wizard_leverage, + handle_mgs_wizard_amount, + handle_mgs_interval_change, + handle_mgs_save, + handle_mgs_back_to_connector, + handle_mgs_back_to_pair, + handle_mgs_back_to_leverage, + handle_mgs_back_to_amount, + handle_mgs_pair_select, + handle_mgs_grid_type, + handle_mgs_num_grids, + handle_mgs_position_mode, + handle_mgs_back_to_position_mode, + handle_mgs_back_to_grid_type, + handle_mgs_back_to_num_grids, + process_mgs_wizard_input, -def list_controllers() -> Dict[str, Type[BaseController]]: - """ - Get all registered controllers. +# ── DMan V3 ─────────────────────────────────────────────────────────────── + show_new_dman_v3_form, + handle_dman_wizard_connector, + handle_dman_wizard_pair, + handle_dman_wizard_leverage, + handle_dman_wizard_amount, + handle_dman_position_mode, + handle_dman_back_to_position_mode, + handle_dman_interval_change, + handle_dman_set_strategy, + handle_dman_save, + handle_dman_pair_select, + handle_dman_back_to_connector, + handle_dman_back_to_pair, + handle_dman_back_to_leverage, + handle_dman_back_to_amount, + process_dman_wizard_input, +# ── Arbitrage Controller ────────────────────────────────────────────────── + show_new_arbitrage_controller_form, + handle_arb_wizard_connector_1, + handle_arb_wizard_connector_2, + handle_arb_wizard_pair_1, + handle_arb_wizard_pair_2, + handle_arb_wizard_amount, + handle_arb_save, + handle_arb_back_to_connector_1, + handle_arb_back_to_connector_2, + handle_arb_back_to_pair_1, + handle_arb_back_to_amount, + process_arb_wizard_input, + handle_arb_pair_select, + handle_arb_proceed_anyway, +# ── XEMM Multiple Levels ────────────────────────────────────────────────── + show_new_xemm_multiple_levels_form, + handle_xemm_maker_connector, + handle_xemm_maker_pair, + handle_xemm_taker_connector, + handle_xemm_taker_pair, + handle_xemm_wizard_amount, + handle_xemm_save, + handle_xemm_back_to_maker_connector, + handle_xemm_back_to_taker_connector, + handle_xemm_back_to_pair, + handle_xemm_back_to_amount, + handle_xemm_proceed_anyway, + process_xemm_wizard_input, - Returns: - Dict mapping controller type to controller class - """ - return _CONTROLLER_REGISTRY.copy() +# ── MACD-BB Levels ────────────────────────────────────────────────── + show_new_macd_bb_v1_form, + handle_macdbb_wizard_connector, + handle_macdbb_wizard_pair, + handle_macdbb_wizard_leverage, + handle_macdbb_wizard_amount, + handle_macdbb_position_mode, + handle_macdbb_back_to_position_mode, + handle_macdbb_interval_change, + handle_macdbb_save, + handle_macdbb_back_to_connector, + handle_macdbb_back_to_pair, + handle_macdbb_back_to_leverage, + handle_macdbb_back_to_amount, + process_macdbb_wizard_input, + handle_macdbb_pair_select, + handle_macdbb_set_strategy, +# ── Supertrend ────────────────────────────────────────────────── + show_new_supertrend_v1_form, + handle_st_wizard_connector, + handle_st_wizard_pair, + handle_st_wizard_leverage, + handle_st_wizard_amount, + handle_st_interval_change, + handle_st_save, + handle_st_back_to_connector, + handle_st_back_to_pair, + handle_st_back_to_leverage, + handle_st_back_to_amount, + handle_st_position_mode, + handle_st_back_to_position_mode, + handle_st_set_strategy, + process_st_wizard_input, +# ── Anti-Folla ────────────────────────────────────────────────────────── + show_new_anti_folla_v1_form, + handle_af_wizard_connector, + handle_af_wizard_pair, + handle_af_wizard_leverage, + handle_af_wizard_amount, + handle_af_interval_change, + handle_af_save, + handle_af_back_to_connector, + handle_af_back_to_pair, + handle_af_back_to_leverage, + handle_af_back_to_amount, + process_af_wizard_input, + handle_af_position_mode, + handle_af_back_to_position_mode, + handle_af_set_strategy, +# ── Funding Rate Arbitrage ────────────────────────────────────────────────────────── + show_new_funding_rate_arb_form, + handle_fra_wizard_connector_1, + handle_fra_wizard_pair_1, + handle_fra_wizard_connector_2, + handle_fra_wizard_pair_2, + handle_fra_wizard_amount, + handle_fra_save, + handle_fra_back_to_connector_1, + handle_fra_back_to_pair_1, + handle_fra_back_to_connector_2, + handle_fra_back_to_pair_2, + handle_fra_back_to_amount, + process_fra_wizard_input, +# ── Delta Neutral mm ────────────────────────────────────────────────────────────── + show_new_delta_neutral_mm_form, + handle_dnmm_wizard_maker_connector, + handle_dnmm_wizard_maker_pair, + handle_dnmm_wizard_hedge_connector, + handle_dnmm_wizard_hedge_pair, + handle_dnmm_wizard_amount, + handle_dnmm_save, + handle_dnmm_back_to_maker_connector, + handle_dnmm_back_to_maker_pair, + handle_dnmm_back_to_hedge_connector, + handle_dnmm_back_to_hedge_pair, + handle_dnmm_back_to_amount, + process_dnmm_wizard_input, +# ── Bollinger Grid wizard ────────────────────────────────────────────────────────────── + show_new_bollingrid_form, + handle_bg_wizard_connector, + handle_bg_wizard_pair, + handle_bg_wizard_leverage, + handle_bg_position_mode, + handle_bg_wizard_amount, + handle_bg_save, + handle_bg_interval_change, + handle_bg_back_to_connector, + handle_bg_back_to_pair, + handle_bg_back_to_leverage, + handle_bg_back_to_amount, + handle_bg_back_to_position_mode, + handle_bg_pair_select, + process_bg_wizard_input, +# ── Quantum Grid Allocator wizard ────────────────────────────────────────────────────────────── + show_new_quantum_grid_allocator_form, + handle_qga_wizard_connector, + handle_qga_wizard_quote, + handle_qga_add_asset, + handle_qga_alloc_next, + handle_qga_amount_step, + handle_qga_wizard_amount, + handle_qga_save, + handle_qga_back_to_connector, + handle_qga_back_to_quote, + handle_qga_back_to_portfolio, + handle_qga_amount, + handle_qga_back_to_grid_params, + handle_qga_back_to_amount, + process_qga_wizard_input, +# ── StatArb V2 ─────────────────────────────────────────────────────────── + show_new_stat_arb_v2_form, + handle_stat_arb_wizard_connector, + handle_stat_arb_base_asset, + handle_stat_arb_quote_asset_1, + handle_stat_arb_quote_asset_2, + handle_stat_arb_back_to_connector, + handle_stat_arb_back_to_base_asset, + handle_stat_arb_back_to_quote_1, + handle_stat_arb_back_to_quote_2, + handle_stat_arb_back_to_amount, + handle_stat_arb_back_to_leverage, # <-- AGGIUNGI + handle_stat_arb_wizard_leverage, + handle_stat_arb_wizard_amount, + handle_stat_arb_save, + handle_stat_arb_interval_change, + process_stat_arb_wizard_input, +# LM Multi Pair DEX wizard + show_new_lm_multi_pair_dex_form, + handle_lmp_wizard_connector, + handle_lmp_toggle_pair, + handle_lmp_next_markets, + handle_lmp_token, + handle_lmp_allocation, + handle_lmp_wizard_amount, + handle_lmp_save, + handle_lmp_back_to_connector, + handle_lmp_back_to_markets, + handle_lmp_back_to_token, + handle_lmp_back_to_allocation, + handle_lmp_back_to_amount, + process_lmp_wizard_input, + handle_lmp_pair_select, + handle_save_config, + handle_select_all, + handle_select_connector, + handle_select_credentials, + handle_select_image, + handle_select_instance_name, + handle_set_field, + handle_toggle_deploy_selection, + handle_toggle_position_mode, + handle_toggle_side, + handle_upload_cancel, + process_cfg_edit_input, + process_deploy_custom_name_input, + process_deploy_field_input, + process_field_input, + process_gs_wizard_input, + process_instance_name_input, + process_pmm_wizard_input, + show_cfg_edit_form, + show_config_form, + show_configs_by_type, + show_configs_list, + show_controller_configs_menu, + show_deploy_config_step, + show_deploy_configure, + show_deploy_form, + show_deploy_menu, + show_new_grid_strike_form, + show_new_pmm_mister_form, + show_type_selector, + show_upload_config_prompt, +) -def get_supported_controller_types() -> List[str]: - """Get list of supported controller type identifiers.""" - return list(_CONTROLLER_REGISTRY.keys()) +# Import submodule handlers +from .menu import ( # Controller chart & edit + handle_back_to_bot, + handle_clone_controller, + handle_close, + handle_confirm_start_controller, + handle_confirm_stop_bot, + handle_confirm_stop_controller, + handle_controller_confirm_set, + handle_controller_set_field, + handle_quick_start_controller, + handle_quick_stop_controller, + handle_refresh, + handle_refresh_bot, + handle_refresh_controller, + handle_start_controller, + handle_stop_bot, + handle_stop_controller, + process_controller_field_input, + show_bot_detail, + show_bot_logs, + show_bots_menu, + show_controller_chart, + show_controller_detail, + show_controller_edit, +) +logger = logging.getLogger(__name__) -def get_controller_info() -> Dict[str, Dict[str, str]]: + +# ============================================ +# MAIN BOTS COMMAND +# ============================================ + + +@restricted +@hummingbot_api_required +async def bots_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """ - Get display info for all controllers. + Handle /bots command - Display bots dashboard - Returns: - Dict mapping type to {name, description} + Usage: + /bots - Show bots dashboard with status and controller options + /bots - Show detailed status for a specific bot """ - return { - ctrl_type: { - "name": ctrl.display_name, - "description": ctrl.description, - } - for ctrl_type, ctrl in _CONTROLLER_REGISTRY.items() - } - - -# For backwards compatibility, also export the registry as SUPPORTED_CONTROLLERS -SUPPORTED_CONTROLLERS = { - ctrl_type: { - "name": ctrl.display_name, - "description": ctrl.description, - "defaults": ctrl.get_defaults(), - "fields": { - name: { - "label": field.label, - "type": field.type, - "required": field.required, - "hint": field.hint, - } - for name, field in ctrl.get_fields().items() - }, - "field_order": ctrl.get_field_order(), - } - for ctrl_type, ctrl in _CONTROLLER_REGISTRY.items() -} + # Clear all pending input states to prevent interference + clear_all_input_states(context) + + # Get the appropriate message object for replies + msg = update.message or ( + update.callback_query.message if update.callback_query else None + ) + if not msg: + logger.error("No message object available for bots_command") + return + + await msg.reply_chat_action("typing") + + # 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(chat_id, context.user_data) + 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") + except Exception as e: + logger.error(f"Error fetching bot status: {e}", exc_info=True) + error_message = format_error_message( + f"Failed to fetch bot status: {str(e)}" + ) + await msg.reply_text(error_message, parse_mode="MarkdownV2") + return + + # Show the interactive menu + await show_bots_menu(update, context) + + +@restricted +@hummingbot_api_required +async def new_bot_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /new_bot command - Show controller configs menu for creating new bots""" + clear_all_input_states(context) + msg = update.message or ( + update.callback_query.message if update.callback_query else None + ) + if msg: + await msg.reply_chat_action("typing") + await show_controller_configs_menu(update, context) + + +# ============================================ +# CALLBACK HANDLER +# ============================================ + + +@restricted +async def bots_callback_handler( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Handle inline button callbacks - Routes to appropriate handler""" + query = update.callback_query + await query.answer() + + try: + callback_parts = query.data.split(":", 1) + action = callback_parts[1] if len(callback_parts) > 1 else query.data + + # Parse action and any additional parameters + action_parts = action.split(":") + main_action = action_parts[0] + + # Menu navigation + if main_action == "main_menu": + await show_bots_menu(update, context) + + elif main_action == "refresh": + await handle_refresh(update, context) + + elif main_action == "close": + await handle_close(update, context) + + # Controller configs menu + elif main_action == "controller_configs": + await show_controller_configs_menu(update, context) + + elif main_action == "configs_page": + if len(action_parts) > 1: + page = int(action_parts[1]) + await handle_configs_page(update, context, page) + + 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 == "cfg_branch": + await handle_cfg_branch(update, context) + + # Custom config upload + elif main_action == "upload_config": + await show_upload_config_prompt(update, context) + + elif main_action == "upload_cancel": + await handle_upload_cancel(update, context) + + elif main_action == "noop": + pass # Do nothing - used for pagination display button + + elif main_action == "new_grid_strike": + await show_new_grid_strike_form(update, context) + + elif main_action == "new_pmm_mister": + await show_new_pmm_mister_form(update, context) + + elif main_action == "new_pmm_v1": + await show_new_pmm_v1_form(update, context) + + elif main_action == "pv1_connector": + if len(action_parts) > 1: + connector = action_parts[1] + await handle_pv1_wizard_connector(update, context, connector) + + elif main_action == "pv1_pair": + if len(action_parts) > 1: + pair = action_parts[1] + await handle_pv1_wizard_pair(update, context, pair) + + elif main_action == "pv1_pair_select": + if len(action_parts) > 1: + pair = action_parts[1] + await handle_pv1_pair_select(update, context, pair) + + elif main_action == "pv1_amount": + if len(action_parts) > 1: + amount = action_parts[1] + await handle_pv1_wizard_amount(update, context, amount) + + elif main_action == "pv1_spreads": + if len(action_parts) > 1: + spread = action_parts[1] + await handle_pv1_wizard_spreads(update, context, spread) + + elif main_action == "pv1_back": + if len(action_parts) > 1: + target = action_parts[1] + await handle_pv1_back(update, context, target) + + elif main_action == "pv1_save": + await handle_pv1_save(update, context) + + elif main_action == "pv1_review_back": + await handle_pv1_review_back(update, context) + + elif main_action == "edit_config": + if len(action_parts) > 1: + config_index = int(action_parts[1]) + await handle_edit_config(update, context, config_index) + + elif main_action == "edit_config_back": + await show_config_form(update, context) + + elif main_action == "set_field": + if len(action_parts) > 1: + field_name = action_parts[1] + await handle_set_field(update, context, field_name) + + elif main_action == "toggle_side": + await handle_toggle_side(update, context) + + elif main_action == "toggle_position_mode": + await handle_toggle_position_mode(update, context) + + elif main_action == "cycle_order_type": + if len(action_parts) > 1: + order_type_key = action_parts[1] # 'open' or 'tp' + await handle_cycle_order_type(update, context, order_type_key) + + elif main_action == "select_connector": + if len(action_parts) > 1: + connector_name = action_parts[1] + await handle_select_connector(update, context, connector_name) + + elif main_action == "save_config": + await handle_save_config(update, context) + + # Deploy menu + elif main_action == "deploy_menu": + await show_deploy_menu(update, context) + + elif main_action == "toggle_deploy": + if len(action_parts) > 1: + index = int(action_parts[1]) + await handle_toggle_deploy_selection(update, context, index) + + elif main_action == "select_all": + await handle_select_all(update, context) + + elif main_action == "clear_all": + await handle_clear_all(update, context) + + elif main_action == "deploy_configure": + await show_deploy_configure(update, context) + + elif main_action == "deploy_form_back": + await show_deploy_form(update, context) + + elif main_action == "deploy_set": + if len(action_parts) > 1: + field_name = action_parts[1] + await handle_deploy_set_field(update, context, field_name) + + elif main_action == "execute_deploy": + await handle_execute_deploy(update, context) + + # Progressive deploy flow + elif main_action == "deploy_use_default": + if len(action_parts) > 1: + field_name = action_parts[1] + await handle_deploy_use_default(update, context, field_name) + + elif main_action == "deploy_skip_field": + await handle_deploy_skip_field(update, context) + + elif main_action == "deploy_prev_field": + await handle_deploy_prev_field(update, context) + + elif main_action == "deploy_edit": + if len(action_parts) > 1: + field_name = action_parts[1] + await handle_deploy_edit_field(update, context, field_name) + + # Streamlined deploy flow + elif main_action == "deploy_config": + await show_deploy_config_step(update, context) + + elif main_action == "select_creds": + if len(action_parts) > 1: + creds = action_parts[1] + await handle_select_credentials(update, context, creds) + + elif main_action == "select_image": + if len(action_parts) > 1: + # Rejoin parts to preserve colons in image tag (e.g., "hummingbot:development") + image = ":".join(action_parts[1:]) + await handle_select_image(update, context, image) + + elif main_action == "select_name": + if len(action_parts) > 1: + name = action_parts[1] + await handle_select_instance_name(update, context, name) + + elif main_action == "deploy_confirm": + await handle_deploy_confirm(update, context) + + elif main_action == "deploy_custom_name": + await handle_deploy_custom_name(update, context) + + # Progressive Grid Strike wizard + elif main_action == "gs_connector": + if len(action_parts) > 1: + connector = action_parts[1] + await handle_gs_wizard_connector(update, context, connector) + + elif main_action == "gs_pair": + if len(action_parts) > 1: + pair = action_parts[1] + await handle_gs_wizard_pair(update, context, pair) + + elif main_action == "gs_pair_select": + if len(action_parts) > 1: + pair = action_parts[1] + await handle_gs_pair_select(update, context, pair) + + elif main_action == "gs_side": + if len(action_parts) > 1: + side_str = action_parts[1] + await handle_gs_wizard_side(update, context, side_str) + + elif main_action == "gs_leverage": + if len(action_parts) > 1: + leverage = int(action_parts[1]) + await handle_gs_wizard_leverage(update, context, leverage) + + elif main_action == "gs_amount": + if len(action_parts) > 1: + amount = float(action_parts[1]) + await handle_gs_wizard_amount(update, context, amount) + + elif main_action == "gs_accept_prices": + await handle_gs_accept_prices(update, context) + + 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] + await handle_gs_interval_change(update, context, interval) + + elif main_action == "gs_edit_price": + if len(action_parts) > 1: + price_type = action_parts[1] + await handle_gs_edit_price(update, context, price_type) + + elif main_action == "gs_tp": + if len(action_parts) > 1: + tp = float(action_parts[1]) + await handle_gs_wizard_take_profit(update, context, tp) + + elif main_action == "gs_edit_id": + await handle_gs_edit_id(update, context) + + elif main_action == "gs_edit_keep": + await handle_gs_edit_keep(update, context) + + elif main_action == "gs_edit_tp": + await handle_gs_edit_tp(update, context) + + elif main_action == "gs_edit_act": + await handle_gs_edit_act(update, context) + + elif main_action == "gs_edit_max_orders": + await handle_gs_edit_max_orders(update, context) + + elif main_action == "gs_edit_batch": + await handle_gs_edit_batch(update, context) + + elif main_action == "gs_edit_min_amt": + await handle_gs_edit_min_amt(update, context) + + elif main_action == "gs_edit_spread": + await handle_gs_edit_spread(update, context) + + elif main_action == "gs_save": + await handle_gs_save(update, context) + + 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_pair_select": + if len(action_parts) > 1: + pair = action_parts[1] + await handle_pmm_pair_select(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_amount": + if len(action_parts) > 1: + amount = float(action_parts[1]) + await handle_pmm_wizard_amount(update, context, amount) + + 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_back": + if len(action_parts) > 1: + target = action_parts[1] + await handle_pmm_back(update, context, target) + + elif main_action == "pmm_save": + await handle_pmm_save(update, context) + + elif main_action == "pmm_review_back": + await handle_pmm_review_back(update, context) + + elif main_action == "pmm_edit_id": + await handle_pmm_edit_id(update, context) + + elif main_action == "pmm_edit": + if len(action_parts) > 1: + field = action_parts[1] + await handle_pmm_edit_field(update, context, field) + + elif main_action == "pmm_set": + if len(action_parts) > 2: + field = action_parts[1] + value = action_parts[2] + await handle_pmm_set_field(update, context, field, value) + + elif main_action == "pmm_edit_advanced": + await handle_pmm_edit_advanced(update, context) + + elif main_action == "pmm_adv": + if len(action_parts) > 1: + setting = action_parts[1] + await handle_pmm_adv_setting(update, context, setting) + +# ===== StatArb V2 wizard ===== + elif main_action == "new_stat_arb_v2": + await show_new_stat_arb_v2_form(update, context) + elif main_action == "stat_arb_connector": + if len(action_parts) > 1: + connector = action_parts[1] + await handle_stat_arb_wizard_connector(update, context, connector) + elif main_action == "stat_arb_base_asset": + if len(action_parts) > 1: + asset = action_parts[1] + await handle_stat_arb_base_asset(update, context, asset) + elif main_action == "stat_arb_quote_1": + if len(action_parts) > 1: + pair = action_parts[1] + await handle_stat_arb_quote_asset_1(update, context, pair) + elif main_action == "stat_arb_quote_2": + if len(action_parts) > 1: + pair = action_parts[1] + await handle_stat_arb_quote_asset_2(update, context, pair) + elif main_action == "stat_arb_back_to_base_asset": + await handle_stat_arb_back_to_base_asset(update, context) + elif main_action == "stat_arb_back_to_quote_1": + await handle_stat_arb_back_to_quote_1(update, context) + elif main_action == "stat_arb_back_to_quote_2": + await handle_stat_arb_back_to_quote_2(update, context) + elif main_action == "stat_arb_back_to_leverage": + await handle_stat_arb_back_to_leverage(update, context) + elif main_action == "stat_arb_leverage": + if len(action_parts) > 1: + leverage = int(action_parts[1]) + await handle_stat_arb_wizard_leverage(update, context, leverage) + elif main_action == "stat_arb_amount": + if len(action_parts) > 1: + amount = float(action_parts[1]) + await handle_stat_arb_wizard_amount(update, context, amount) + elif main_action == "stat_arb_save": + await handle_stat_arb_save(update, context) + elif main_action == "stat_arb_back_to_connector": + await handle_stat_arb_back_to_connector(update, context) + elif main_action == "stat_arb_back_to_amount": + await handle_stat_arb_back_to_amount(update, context) + elif main_action == "stat_arb_interval": + if len(action_parts) > 1: + interval = action_parts[1] + await handle_stat_arb_interval_change(update, context, interval) +# ===== Dman v3 ===== + elif main_action == "new_dman_v3": + await show_new_dman_v3_form(update, context) + elif main_action == "dman_connector": + if len(action_parts) > 1: + await handle_dman_wizard_connector(update, context, action_parts[1]) + elif main_action == "dman_pair": + if len(action_parts) > 1: + pair = action_parts[1] + await handle_dman_wizard_pair(update, context, pair) + elif main_action == "dman_pair_select": + if len(action_parts) > 1: + pair = action_parts[1] + await handle_dman_pair_select(update, context, pair) + elif main_action == "dman_leverage": + if len(action_parts) > 1: + await handle_dman_wizard_leverage(update, context, int(action_parts[1])) + elif main_action == "dman_position_mode": + if len(action_parts) > 1: + mode = action_parts[1] + await handle_dman_position_mode(update, context, mode) + elif main_action == "dman_back_to_position_mode": + await handle_dman_back_to_position_mode(update, context) + elif main_action == "dman_amount": + if len(action_parts) > 1: + await handle_dman_wizard_amount(update, context, float(action_parts[1])) + elif main_action == "dman_interval": + if len(action_parts) > 1: + await handle_dman_interval_change(update, context, action_parts[1]) + elif main_action == "dman_set_strat": + if len(action_parts) > 1: + strategy_name = action_parts[1] + await handle_dman_set_strategy(update, context, strategy_name) + elif main_action == "dman_save": + await handle_dman_save(update, context) + elif main_action == "dman_back_to_connector": + await handle_dman_back_to_connector(update, context) + elif main_action == "dman_back_to_pair": + await handle_dman_back_to_pair(update, context) + elif main_action == "dman_back_to_leverage": + await handle_dman_back_to_leverage(update, context) + elif main_action == "dman_back_to_amount": + await handle_dman_back_to_amount(update, context) +# ===== Arbitrage ===== + elif main_action == "new_arbitrage_controller": + await show_new_arbitrage_controller_form(update, context) + elif main_action == "arb_connector_1": + if len(action_parts) > 1: + await handle_arb_wizard_connector_1(update, context, action_parts[1]) + elif main_action == "arb_connector_2": + if len(action_parts) > 1: + await handle_arb_wizard_connector_2(update, context, action_parts[1]) + elif main_action == "arb_pair_1": + if len(action_parts) > 1: + await handle_arb_wizard_pair_1(update, context, action_parts[1]) + elif main_action == "arb_pair_2": + if len(action_parts) > 1: + pair = ":".join(action_parts[1:]) + await handle_arb_wizard_pair_2(update, context, pair) + elif main_action == "arb_amount": + if len(action_parts) > 1: + await handle_arb_wizard_amount(update, context, float(action_parts[1])) + elif main_action == "arb_save": + await handle_arb_save(update, context) + elif main_action == "arb_back_to_connector_1": + await handle_arb_back_to_connector_1(update, context) + elif main_action == "arb_back_to_connector_2": + await handle_arb_back_to_connector_2(update, context) + elif main_action == "arb_back_to_pair_1": + await handle_arb_back_to_pair_1(update, context) + elif main_action == "arb_back_to_pair_2": + await handle_arb_back_to_pair_1(update, context) + elif main_action == "arb_back_to_amount": + await handle_arb_back_to_amount(update, context) + elif main_action == "arb_pair_select": + if len(action_parts) > 1: + pair = action_parts[1] + await handle_arb_pair_select(update, context, pair) + elif main_action == "arb_proceed_anyway": + await handle_arb_proceed_anyway(update, context) +# ===== Xemm ===== + elif main_action == "new_xemm_multiple_levels": + await show_new_xemm_multiple_levels_form(update, context) + elif main_action == "xemm_maker_connector": + if len(action_parts) > 1: + await handle_xemm_maker_connector(update, context, action_parts[1]) + elif main_action == "xemm_taker_connector": + if len(action_parts) > 1: + await handle_xemm_taker_connector(update, context, action_parts[1]) + elif main_action == "xemm_maker_pair": + if len(action_parts) > 1: + pair = ":".join(action_parts[1:]) + await handle_xemm_maker_pair(update, context, pair) + elif main_action == "xemm_taker_pair": + if len(action_parts) > 1: + pair = ":".join(action_parts[1:]) + await handle_xemm_taker_pair(update, context, pair) + elif main_action == "xemm_amount": + if len(action_parts) > 1: + await handle_xemm_wizard_amount(update, context, float(action_parts[1])) + elif main_action == "xemm_save": + await handle_xemm_save(update, context) + elif main_action == "xemm_back_to_maker_connector": + await handle_xemm_back_to_maker_connector(update, context) + elif main_action == "xemm_back_to_taker_connector": + await handle_xemm_back_to_taker_connector(update, context) + elif main_action == "xemm_back_to_pair": + await handle_xemm_back_to_pair(update, context) + elif main_action == "xemm_back_to_amount": + await handle_xemm_back_to_amount(update, context) + elif main_action == "xemm_proceed_anyway": + await handle_xemm_proceed_anyway(update, context) +#MACDBB + elif main_action == "new_macd_bb_v1": + await show_new_macd_bb_v1_form(update, context) + elif main_action == "macdbb_connector": + if len(action_parts) > 1: + await handle_macdbb_wizard_connector(update, context, action_parts[1]) + elif main_action == "macdbb_pair": + if len(action_parts) > 1: + await handle_macdbb_wizard_pair(update, context, action_parts[1]) + elif main_action == "macdbb_pair_select": + if len(action_parts) > 1: + pair = action_parts[1] + await handle_macdbb_pair_select(update, context, pair) + elif main_action == "macdbb_leverage": + if len(action_parts) > 1: + await handle_macdbb_wizard_leverage(update, context, int(action_parts[1])) + elif main_action == "macdbb_amount": + if len(action_parts) > 1: + await handle_macdbb_wizard_amount(update, context, float(action_parts[1])) + elif main_action == "macdbb_position_mode": + if len(action_parts) > 1: + mode = action_parts[1] + await handle_macdbb_position_mode(update, context, mode) + elif main_action == "macdbb_interval": + if len(action_parts) > 1: + await handle_macdbb_interval_change(update, context, action_parts[1]) + elif main_action == "macdbb_save": + await handle_macdbb_save(update, context) + elif main_action == "macdbb_back_to_connector": + await handle_macdbb_back_to_connector(update, context) + elif main_action == "macdbb_back_to_pair": + await handle_macdbb_back_to_pair(update, context) + elif main_action == "macdbb_back_to_leverage": + await handle_macdbb_back_to_leverage(update, context) + elif main_action == "macdbb_back_to_amount": + await handle_macdbb_back_to_amount(update, context) + elif main_action == "macdbb_back_to_position_mode": + await handle_macdbb_back_to_position_mode(update, context) + elif main_action == "macdbb_set_strat": + if len(action_parts) > 1: + strategy_name = action_parts[1] + await handle_macdbb_set_strategy(update, context, strategy_name) +#SUPERTREND + elif main_action == "new_supertrend_v1": + await show_new_supertrend_v1_form(update, context) + elif main_action == "st_connector": + if len(action_parts) > 1: + await handle_st_wizard_connector(update, context, action_parts[1]) + elif main_action == "st_pair": + if len(action_parts) > 1: + await handle_st_wizard_pair(update, context, action_parts[1]) + elif main_action == "st_pair_select": + if len(action_parts) > 1: + pair = action_parts[1] + await handle_st_wizard_pair(update, context, pair) + elif main_action == "st_leverage": + if len(action_parts) > 1: + await handle_st_wizard_leverage(update, context, int(action_parts[1])) + elif main_action == "st_position_mode": + if len(action_parts) > 1: + mode = action_parts[1] + await handle_st_position_mode(update, context, mode) + elif main_action == "st_back_to_position_mode": + await handle_st_back_to_position_mode(update, context) + elif main_action == "st_amount": + if len(action_parts) > 1: + await handle_st_wizard_amount(update, context, float(action_parts[1])) + elif main_action == "st_interval": + if len(action_parts) > 1: + await handle_st_interval_change(update, context, action_parts[1]) + elif main_action == "st_set_strat": + if len(action_parts) > 1: + strategy_name = action_parts[1] + await handle_st_set_strategy(update, context, strategy_name) + elif main_action == "st_save": + await handle_st_save(update, context) + elif main_action == "st_back_to_connector": + await handle_st_back_to_connector(update, context) + elif main_action == "st_back_to_pair": + await handle_st_back_to_pair(update, context) + elif main_action == "st_back_to_leverage": + await handle_st_back_to_leverage(update, context) + elif main_action == "st_back_to_amount": + await handle_st_back_to_amount(update, context) +#ANTI-FOLLA + elif main_action == "new_anti_folla_v1": + await show_new_anti_folla_v1_form(update, context) + elif main_action == "af_connector": + if len(action_parts) > 1: + await handle_af_wizard_connector(update, context, action_parts[1]) + elif main_action == "af_pair": + if len(action_parts) > 1: + await handle_af_wizard_pair(update, context, action_parts[1]) + + elif main_action == "af_pair_select": + if len(action_parts) > 1: + pair = action_parts[1] + await handle_af_wizard_pair(update, context, pair) + elif main_action == "af_position_mode": + if len(action_parts) > 1: + mode = action_parts[1] + await handle_af_position_mode(update, context, mode) + elif main_action == "af_back_to_position_mode": + await handle_af_back_to_position_mode(update, context) + elif main_action == "af_leverage": + if len(action_parts) > 1: + await handle_af_wizard_leverage(update, context, int(action_parts[1])) + elif main_action == "af_amount": + if len(action_parts) > 1: + await handle_af_wizard_amount(update, context, float(action_parts[1])) + elif main_action == "af_interval": + if len(action_parts) > 1: + await handle_af_interval_change(update, context, action_parts[1]) + elif main_action == "af_set_strat": + if len(action_parts) > 1: + strategy_name = action_parts[1] + await handle_af_set_strategy(update, context, strategy_name) + elif main_action == "af_save": + await handle_af_save(update, context) + elif main_action == "af_back_to_connector": + await handle_af_back_to_connector(update, context) + elif main_action == "af_back_to_pair": + await handle_af_back_to_pair(update, context) + elif main_action == "af_back_to_leverage": + await handle_af_back_to_leverage(update, context) + elif main_action == "af_back_to_amount": + await handle_af_back_to_amount(update, context) +# Funding Rate Arbitrage + elif main_action == "new_funding_rate_arb": + await show_new_funding_rate_arb_form(update, context) + elif main_action == "fra_connector_1": + if len(action_parts) > 1: + await handle_fra_wizard_connector_1(update, context, action_parts[1]) + elif main_action == "fra_pair_1": + if len(action_parts) > 1: + await handle_fra_wizard_pair_1(update, context, action_parts[1]) + elif main_action == "fra_connector_2": + if len(action_parts) > 1: + await handle_fra_wizard_connector_2(update, context, action_parts[1]) + elif main_action == "fra_pair_2": + if len(action_parts) > 1: + await handle_fra_wizard_pair_2(update, context, action_parts[1]) + elif main_action == "fra_amount": + if len(action_parts) > 1: + await handle_fra_wizard_amount(update, context, float(action_parts[1])) + elif main_action == "fra_save": + await handle_fra_save(update, context) + elif main_action == "fra_back_to_connector_1": + await handle_fra_back_to_connector_1(update, context) + elif main_action == "fra_back_to_pair_1": + await handle_fra_back_to_pair_1(update, context) + elif main_action == "fra_back_to_connector_2": + await handle_fra_back_to_connector_2(update, context) + elif main_action == "fra_back_to_pair_2": + await handle_fra_back_to_pair_2(update, context) + elif main_action == "fra_back_to_amount": + await handle_fra_back_to_amount(update, context) +# Delta Neutral MM + elif main_action == "new_delta_neutral_mm": + await show_new_delta_neutral_mm_form(update, context) + elif main_action == "dnmm_maker_connector": + if len(action_parts) > 1: + await handle_dnmm_wizard_maker_connector(update, context, action_parts[1]) + elif main_action == "dnmm_maker_pair": + if len(action_parts) > 1: + await handle_dnmm_wizard_maker_pair(update, context, action_parts[1]) + elif main_action == "dnmm_hedge_connector": + if len(action_parts) > 1: + await handle_dnmm_wizard_hedge_connector(update, context, action_parts[1]) + elif main_action == "dnmm_hedge_pair": + if len(action_parts) > 1: + await handle_dnmm_wizard_hedge_pair(update, context, action_parts[1]) + elif main_action == "dnmm_amount": + if len(action_parts) > 1: + await handle_dnmm_wizard_amount(update, context, float(action_parts[1])) + elif main_action == "dnmm_save": + await handle_dnmm_save(update, context) + elif main_action == "dnmm_back_to_maker_connector": + await handle_dnmm_back_to_maker_connector(update, context) + elif main_action == "dnmm_back_to_maker_pair": + await handle_dnmm_back_to_maker_pair(update, context) + elif main_action == "dnmm_back_to_hedge_connector": + await handle_dnmm_back_to_hedge_connector(update, context) + elif main_action == "dnmm_back_to_hedge_pair": + await handle_dnmm_back_to_hedge_pair(update, context) + elif main_action == "dnmm_back_to_amount": + await handle_dnmm_back_to_amount(update, context) +# Bollinger Grid (wizard) + elif main_action == "new_bollingrid": + await show_new_bollingrid_form(update, context) + elif main_action == "bg_connector": + if len(action_parts) > 1: + await handle_bg_wizard_connector(update, context, action_parts[1]) + elif main_action == "bg_pair": + if len(action_parts) > 1: + await handle_bg_wizard_pair(update, context, action_parts[1]) + elif main_action == "bg_pair_select": + if len(action_parts) > 1: + await handle_bg_pair_select(update, context, action_parts[1]) + elif main_action == "bg_leverage": + if len(action_parts) > 1: + await handle_bg_wizard_leverage(update, context, int(action_parts[1])) + elif main_action == "bg_position_mode": + if len(action_parts) > 1: + mode = action_parts[1] + await handle_bg_position_mode(update, context, mode) + elif main_action == "bg_amount": + if len(action_parts) > 1: + await handle_bg_wizard_amount(update, context, float(action_parts[1])) + elif main_action == "bg_interval": + if len(action_parts) > 1: + await handle_bg_interval_change(update, context, action_parts[1]) + elif main_action == "bg_save": + await handle_bg_save(update, context) + elif main_action == "bg_back_to_connector": + await handle_bg_back_to_connector(update, context) + elif main_action == "bg_back_to_pair": + await handle_bg_back_to_pair(update, context) + elif main_action == "bg_back_to_leverage": + await handle_bg_back_to_leverage(update, context) + elif main_action == "bg_back_to_position_mode": + await handle_bg_back_to_position_mode(update, context) + elif main_action == "bg_back_to_amount": + await handle_bg_back_to_amount(update, context) +# Quantum Grid Allocator + elif main_action == "new_quantum_grid_allocator": + await show_new_quantum_grid_allocator_form(update, context) + elif main_action == "qga_connector": + if len(action_parts) > 1: + await handle_qga_wizard_connector(update, context, action_parts[1]) + elif main_action == "qga_quote": + if len(action_parts) > 1: + await handle_qga_wizard_quote(update, context, action_parts[1]) + elif main_action == "qga_add_asset": + if len(action_parts) > 2: + asset = action_parts[1] + allocation = float(action_parts[2]) + await handle_qga_add_asset(update, context, asset, allocation) + elif main_action == "qga_alloc_next": + await handle_qga_alloc_next(update, context) + elif main_action == "qga_amount": + if len(action_parts) > 1: + await handle_qga_wizard_amount(update, context, float(action_parts[1])) + elif main_action == "qga_save": + await handle_qga_save(update, context) + elif main_action == "qga_back_to_connector": + await handle_qga_back_to_connector(update, context) + elif main_action == "qga_back_to_quote": + await handle_qga_back_to_quote(update, context) + elif main_action == "qga_back_to_grid_params": + await handle_qga_back_to_grid_params(update, context) + elif main_action == "qga_back_to_amount": + await handle_qga_back_to_amount(update, context) + elif main_action == "qga_next": + await handle_qga_amount(update, context) + elif main_action == "qga_back_to_portfolio": + await handle_qga_back_to_portfolio(update, context) + + # ===== LM Multi Pair DEX wizard ===== + elif main_action == "new_lm_multi_pair_dex": + await show_new_lm_multi_pair_dex_form(update, context) + elif main_action == "lmp_connector": + if len(action_parts) > 1: + connector = action_parts[1] + await handle_lmp_wizard_connector(update, context, connector) + elif main_action == "lmp_toggle_pair": + if len(action_parts) > 1: + pair = action_parts[1] + await handle_lmp_toggle_pair(update, context, pair) + elif main_action == "lmp_next_markets": + await handle_lmp_next_markets(update, context) + elif main_action == "lmp_token": + if len(action_parts) > 1: + token = action_parts[1] + await handle_lmp_token(update, context, token) + elif main_action == "lmp_allocation": + if len(action_parts) > 1: + allocation = float(action_parts[1]) + await handle_lmp_allocation(update, context, allocation) + elif main_action == "lmp_amount": + if len(action_parts) > 1: + amount = float(action_parts[1]) + await handle_lmp_wizard_amount(update, context, amount) + elif main_action == "lmp_save": + await handle_lmp_save(update, context) + elif main_action == "lmp_back_to_connector": + await handle_lmp_back_to_connector(update, context) + elif main_action == "lmp_back_to_markets": + await handle_lmp_back_to_markets(update, context) + elif main_action == "lmp_back_to_token": + await handle_lmp_back_to_token(update, context) + elif main_action == "lmp_back_to_allocation": + await handle_lmp_back_to_allocation(update, context) + elif main_action == "lmp_back_to_amount": + await handle_lmp_back_to_amount(update, context) + + + + + + + + + + + + + # Bot detail + elif main_action == "bot_detail": + if len(action_parts) > 1: + bot_name = action_parts[1] + await show_bot_detail(update, context, bot_name) + + # Controller detail (by index, uses context) + elif main_action == "ctrl_idx": + if len(action_parts) > 1: + idx = int(action_parts[1]) + await show_controller_detail(update, context, idx) + + # Controller chart & edit + elif main_action == "ctrl_chart": + await show_controller_chart(update, context) + + elif main_action == "ctrl_edit": + await show_controller_edit(update, context) + + elif main_action == "ctrl_set": + if len(action_parts) > 1: + field_name = action_parts[1] + await handle_controller_set_field(update, context, field_name) + + elif main_action == "ctrl_confirm_set": + if len(action_parts) > 2: + field_name = action_parts[1] + value = action_parts[2] + await handle_controller_confirm_set(update, context, field_name, value) + + # Stop controller (uses context) + elif main_action == "stop_ctrl": + await handle_stop_controller(update, context) + + elif main_action == "confirm_stop_ctrl": + await handle_confirm_stop_controller(update, context) + + # Start controller (uses context) + elif main_action == "start_ctrl": + await handle_start_controller(update, context) + + elif main_action == "confirm_start_ctrl": + await handle_confirm_start_controller(update, context) + + # Clone controller (PMM Mister only) + elif main_action == "clone_ctrl": + await handle_clone_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) + + elif main_action == "confirm_stop_bot": + await handle_confirm_stop_bot(update, context) + + # View logs (uses context) + elif main_action == "view_logs": + await show_bot_logs(update, context) + + # Navigation + elif main_action == "back_to_bot": + await handle_back_to_bot(update, context) + + 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) + + # Archived bots handlers + elif main_action == "archived": + await show_archived_menu(update, context) + + elif main_action == "archived_page": + if len(action_parts) > 1: + page = int(action_parts[1]) + await show_archived_menu(update, context, page) + + elif main_action == "archived_select": + if len(action_parts) > 1: + db_index = int(action_parts[1]) + await show_archived_detail(update, context, db_index) + + elif main_action == "archived_timeline": + await show_timeline_chart(update, context) + + elif main_action == "archived_chart": + if len(action_parts) > 1: + db_index = int(action_parts[1]) + await show_bot_chart(update, context, db_index) + + elif main_action == "archived_report": + if len(action_parts) > 1: + db_index = int(action_parts[1]) + await handle_generate_report(update, context, db_index) + + elif main_action == "archived_refresh": + await handle_archived_refresh(update, context) + + else: + logger.warning(f"Unknown bots action: {action}") + await query.message.reply_text(f"Unknown action: {action}") + + except Exception as e: + # Ignore "message is not modified" errors + if "not modified" in str(e).lower(): + logger.debug(f"Message not modified (ignored): {e}") + return + + logger.error(f"Error in bots callback handler: {e}", exc_info=True) + from utils.telegram_formatters import format_error_message + + error_message = format_error_message(f"Operation failed: {str(e)}") + try: + await query.message.reply_text(error_message, parse_mode="MarkdownV2") + except Exception as reply_error: + logger.warning(f"Failed to send error message: {reply_error}") + + +# ============================================ +# MESSAGE HANDLER +# ============================================ + + +@restricted +async def bots_message_handler( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Handle user text input - Routes to appropriate processor""" + bots_state = context.user_data.get("bots_state") + + if not bots_state: + return + + user_input = update.message.text.strip() + logger.info(f"Bots message handler - state: {bots_state}, input: {user_input}") + + try: + # Handle controller config field input + if bots_state.startswith("set_field:"): + await process_field_input(update, context, user_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) + elif bots_state.startswith("deploy_set:"): + await process_deploy_field_input(update, context, user_input) + # Handle progressive deploy flow input + elif bots_state == "deploy_progressive": + await handle_deploy_progressive_input(update, context) + # Handle custom instance name input for streamlined deploy + elif bots_state == "deploy_custom_name": + await process_deploy_custom_name_input(update, context, user_input) + # Handle instance name edit in config step + elif bots_state == "deploy_edit_name": + await process_instance_name_input(update, context, user_input) + # Handle Grid Strike wizard input + elif bots_state == "gs_wizard_input": + await process_gs_wizard_input(update, context, user_input) + # Handle PMM Mister wizard input + elif bots_state == "pmm_wizard_input": + await process_pmm_wizard_input(update, context, user_input) + # Handle PMM V1 wizard input + elif bots_state == "pv1_wizard_input": + await process_pv1_wizard_input(update, context, user_input) + elif bots_state == "dman_wizard_input": + await process_dman_wizard_input(update, context, user_input) + elif bots_state == "arb_wizard_input": + await process_arb_wizard_input(update, context, user_input) + elif bots_state == "xemm_wizard_input": + await process_xemm_wizard_input(update, context, update.message.text) + elif bots_state == "macdbb_wizard_input": + await process_macdbb_wizard_input(update, context, user_input) + elif bots_state == "st_wizard_input": + await process_st_wizard_input(update, context, user_input) + elif bots_state == "af_wizard_input": + await process_af_wizard_input(update, context, user_input) + elif bots_state == "fra_wizard_input": + await process_fra_wizard_input(update, context, user_input) + elif bots_state == "dnmm_wizard_input": + await process_dnmm_wizard_input(update, context, user_input) + elif bots_state == "bg_wizard_input": + await process_bg_wizard_input(update, context, user_input) + elif bots_state == "qga_wizard_input": + await process_qga_wizard_input(update, context, user_input) + elif bots_state == "stat_arb_wizard_input": + await process_stat_arb_wizard_input(update, context, user_input) + elif bots_state == "lmp_wizard_input": + await process_lmp_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}") + + except Exception as e: + logger.error(f"Error processing bots input: {e}", exc_info=True) + from utils.telegram_formatters import format_error_message + + error_message = format_error_message(f"Failed to process input: {str(e)}") + await update.message.reply_text(error_message, parse_mode="MarkdownV2") + + +# ============================================ +# HANDLER FACTORIES +# ============================================ + + +def get_bots_callback_handler(): + """Get the callback query handler for bots menu""" + return CallbackQueryHandler(bots_callback_handler, pattern="^bots:") + + +def get_bots_message_handler(): + """Returns the message handler""" + return MessageHandler(filters.TEXT & ~filters.COMMAND, bots_message_handler) + + +@restricted +async def bots_document_handler( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Handle document uploads for bots module (e.g., config file uploads)""" + # Only process if we're expecting a config upload + if context.user_data.get("bots_state") == "awaiting_config_upload": + await handle_config_file_upload(update, context) + + +def get_bots_document_handler(): + """Get the document handler for bots module""" + return MessageHandler(filters.Document.ALL, bots_document_handler) __all__ = [ - # Registry functions - "get_controller", - "list_controllers", - "get_supported_controller_types", - "get_controller_info", - # Base class - "BaseController", - "ControllerField", - # Controller implementations - "GridStrikeController", - "PmmMisterController", - "PmmV1Controller", - "DManV3Controller", - "ArbitrageControllerController", - "XEMMMultipleLevelsController", - "MacdBbV1Controller", - "SuperTrendV1Controller", - "AntiFollaV1Controller", - "FundingRateArbController", - "DeltaNeutralMMController", - "BollinGridController", - "QuantumGridAllocatorController", - "StatArbV2Controller", - "LMMultiPairDEXController", - # Backwards compatibility - "SUPPORTED_CONTROLLERS", + "bots_command", + "bots_callback_handler", + "bots_message_handler", + "bots_document_handler", + "get_bots_callback_handler", + "get_bots_message_handler", + "get_bots_document_handler", ] diff --git a/handlers/bots/controllers/__init__.py b/handlers/bots/controllers/__init__.py index 3c58561a..29f5c5d7 100644 --- a/handlers/bots/controllers/__init__.py +++ b/handlers/bots/controllers/__init__.py @@ -15,12 +15,36 @@ from .grid_strike import GridStrikeController from .pmm_mister import PmmMisterController from .pmm_v1 import PmmV1Controller - +from .arbitrage_controller import ArbitrageControllerController +from .dman_v3 import DManV3Controller +from .multi_grid_strike import MultiGridStrikeController +from .xemm_multiple_levels import XEMMMultipleLevelsController +from .macd_bb_v1 import MacdBbV1Controller +from .supertrend_v1 import SuperTrendV1Controller +from .anti_folla_v1 import AntiFollaV1Controller +from .funding_rate_arb import FundingRateArbController +from .delta_neutral_mm import DeltaNeutralMMController +from .bollingrid import BollinGridController +from .quantum_grid_allocator import QuantumGridAllocatorController +from .stat_arb_v2 import StatArbV2Controller +from .lm_multi_pair_dex import LMMultiPairDEXController # Registry of controller types _CONTROLLER_REGISTRY: Dict[str, Type[BaseController]] = { "grid_strike": GridStrikeController, "pmm_mister": PmmMisterController, "pmm_v1": PmmV1Controller, + "dman_v3": DManV3Controller, + "arbitrage_controller": ArbitrageControllerController, + "xemm_multiple_levels": XEMMMultipleLevelsController, + "macd_bb_v1": MacdBbV1Controller, + "supertrend_v1": SuperTrendV1Controller, + "anti_folla_v1": AntiFollaV1Controller, + "funding_rate_arb": FundingRateArbController, + "delta_neutral_mm": DeltaNeutralMMController, + "bollingrid": BollinGridController, + "quantum_grid_allocator": QuantumGridAllocatorController, + "stat_arb_v2": LMMultiPairDEXController, + "lm_multi_pair_dex": LMMultiPairDEXController, } @@ -102,6 +126,18 @@ def get_controller_info() -> Dict[str, Dict[str, str]]: "GridStrikeController", "PmmMisterController", "PmmV1Controller", + "DManV3Controller", + "ArbitrageControllerController", + "XEMMMultipleLevelsController", + "MacdBbV1Controller", + "SuperTrendV1Controller", + "AntiFollaV1Controller", + "FundingRateArbController", + "DeltaNeutralMMController", + "BollinGridController", + "QuantumGridAllocatorController", + "StatArbV2Controller", + "LMMultiPairDEXController", # Backwards compatibility "SUPPORTED_CONTROLLERS", ] diff --git a/handlers/bots/controllers/anti_folla_v1/__init__.py b/handlers/bots/controllers/anti_folla_v1/__init__.py new file mode 100644 index 00000000..c1fa01de --- /dev/null +++ b/handlers/bots/controllers/anti_folla_v1/__init__.py @@ -0,0 +1,42 @@ +"""Anti-Folla V1 Controller Module - Directional trading with crowd-contrarian indicators.""" + +import io +from typing import Any, Dict, List, Optional, Tuple + +from .._base import BaseController, ControllerField +from .chart import generate_chart, generate_preview_chart +from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config + + +class AntiFollaV1Controller(BaseController): + controller_type = "anti_folla_v1" + display_name = "Anti-Folla V1" + description = "Crowd-contrarian directional trading: VWAP, Donchian, OBV, OBI, Volume Spike, Trade Flow, Funding Rate" + + @classmethod + def get_defaults(cls) -> Dict[str, Any]: + return DEFAULTS.copy() + + @classmethod + def get_fields(cls) -> Dict[str, ControllerField]: + return FIELDS + + @classmethod + def get_field_order(cls) -> List[str]: + return FIELD_ORDER + + @classmethod + def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + 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: + return generate_chart(config, candles_data, current_price) + + @classmethod + def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + return generate_id(config, existing_configs) + + +__all__ = ["AntiFollaV1Controller", "DEFAULTS", "FIELDS", "FIELD_ORDER", "EDITABLE_FIELDS", + "validate_config", "generate_id", "generate_chart", "generate_preview_chart"] diff --git a/handlers/bots/controllers/anti_folla_v1/analysis.py b/handlers/bots/controllers/anti_folla_v1/analysis.py new file mode 100644 index 00000000..4ee969f3 --- /dev/null +++ b/handlers/bots/controllers/anti_folla_v1/analysis.py @@ -0,0 +1,532 @@ +""" +Anti-Folla V1 analysis utilities. + +Pure-Python implementation of crowd-contrarian indicators +(no pandas_ta dependency β€” usable directly from Condor/UI layer): + +- Rolling VWAP +- Donchian Channel (with shift to exclude current candle) +- OBV + divergence detection +- Volume spike detection +- Trade flow analysis (buy/sell pressure from OHLCV) +- Composite score calculation +- Parameter suggestion helpers +""" + +import math +from typing import Any, Dict, List, Optional, Tuple + + +# --------------------------------------------------------------------------- +# LOW-LEVEL CALCULATIONS +# --------------------------------------------------------------------------- + +def calculate_rolling_vwap( + candles: List[Dict[str, Any]], + period: int = 20, +) -> List[float]: + """ + Rolling VWAP = sum(close * volume, N) / sum(volume, N). + Returns a list aligned with candles (NaN-padded as None for first period-1 items). + """ + closes = [float(c.get("close") or c.get("c") or 0) for c in candles] + volumes = [float(c.get("volume") or c.get("v") or 0) for c in candles] + result: List[Optional[float]] = [None] * len(closes) + + for i in range(period - 1, len(closes)): + pv = sum(closes[j] * volumes[j] for j in range(i - period + 1, i + 1)) + vol = sum(volumes[j] for j in range(i - period + 1, i + 1)) + result[i] = pv / vol if vol > 0 else closes[i] + + return [v for v in result if v is not None] + + +def calculate_donchian( + candles: List[Dict[str, Any]], + period: int = 20, +) -> Tuple[List[float], List[float]]: + """ + Donchian Channel with shift(1) β€” excludes the current candle. + Returns (upper_series, lower_series) aligned with candles from index `period`. + """ + highs = [float(c.get("high") or 0) for c in candles] + lows = [float(c.get("low") or 0) for c in candles] + + uppers: List[float] = [] + lowers: List[float] = [] + + # shift(1): window ends at i-1, so range from i-period to i-1 + for i in range(period, len(candles)): + window_h = highs[i - period: i] # shifted: excludes current + window_l = lows[i - period: i] + uppers.append(max(window_h)) + lowers.append(min(window_l)) + + return uppers, lowers + + +def calculate_obv(candles: List[Dict[str, Any]]) -> List[float]: + """Calculate On-Balance Volume.""" + closes = [float(c.get("close") or c.get("c") or 0) for c in candles] + volumes = [float(c.get("volume") or c.get("v") or 0) for c in candles] + + obv = [0.0] + for i in range(1, len(closes)): + if closes[i] > closes[i - 1]: + obv.append(obv[-1] + volumes[i]) + elif closes[i] < closes[i - 1]: + obv.append(obv[-1] - volumes[i]) + else: + obv.append(obv[-1]) + return obv + + +def detect_obv_divergence( + candles: List[Dict[str, Any]], + obv_series: List[float], + lookback: int = 10, +) -> str: + """ + Detect divergence between OBV and price. + + Returns: + 'bullish' – price falls, OBV rises (accumulation) + 'bearish' – price rises, OBV falls (distribution) + 'none' – no divergence + """ + if len(candles) < lookback or len(obv_series) < lookback: + return "none" + + closes = [float(c.get("close") or c.get("c") or 0) for c in candles] + price_trend = closes[-1] - closes[-lookback] + obv_trend = obv_series[-1] - obv_series[-lookback] + + if price_trend < 0 and obv_trend > 0: + return "bullish" + if price_trend > 0 and obv_trend < 0: + return "bearish" + return "none" + + +def detect_volume_spike( + candles: List[Dict[str, Any]], + threshold: float = 2.5, +) -> Tuple[bool, float]: + """Return (is_spike, multiplier). Uses last 20 candles as baseline.""" + volumes = [float(c.get("volume") or c.get("v") or 0) for c in candles] + if len(volumes) < 22: + return False, 1.0 + + avg_vol = sum(volumes[-21:-1]) / 20 + if avg_vol == 0: + return False, 1.0 + + multiplier = volumes[-1] / avg_vol + return multiplier >= threshold, round(multiplier, 2) + + +def analyze_trade_flow( + candles: List[Dict[str, Any]], + lookback: int = 10, +) -> Dict[str, Any]: + """ + Estimate buy/sell pressure and whale activity from OHLCV. + Bullish candles (close > open) = buy pressure, weighted by volume. + Whale proxy: last candle with volume > 3Γ— avg AND body > avg body. + """ + if len(candles) < lookback + 1: + return {"whale_buying": False, "whale_selling": False, "retail_fomo": False, "buy_pressure": 0.5} + + recent = candles[-lookback:] + closes = [float(c.get("close") or 0) for c in recent] + opens_ = [float(c.get("open") or 0) for c in recent] + volumes = [float(c.get("volume") or 0) for c in recent] + + bull_vol = sum(volumes[i] for i in range(len(recent)) if closes[i] > opens_[i]) + bear_vol = sum(volumes[i] for i in range(len(recent)) if closes[i] <= opens_[i]) + total_vol = bull_vol + bear_vol + buy_pressure = bull_vol / total_vol if total_vol > 0 else 0.5 + + avg_vol = sum(volumes) / len(volumes) if volumes else 0 + bodies = [abs(closes[i] - opens_[i]) for i in range(len(recent))] + avg_body = sum(bodies) / len(bodies) if bodies else 0 + + last = candles[-1] + last_close = float(last.get("close") or 0) + last_open = float(last.get("open") or 0) + last_vol = float(last.get("volume") or 0) + last_body = abs(last_close - last_open) + + whale_buying = last_vol > avg_vol * 3.0 and last_close > last_open and last_body > avg_body + whale_selling = last_vol > avg_vol * 3.0 and last_close < last_open and last_body > avg_body + + # Retail FOMO proxy + all_closes = [float(c.get("close") or 0) for c in candles] + price_change_pct = (all_closes[-1] - all_closes[-lookback]) / all_closes[-lookback] if all_closes[-lookback] > 0 else 0 + retail_fomo = bool(price_change_pct > 0.03 and buy_pressure > 0.7 and not whale_buying) + + return { + "whale_buying": bool(whale_buying), + "whale_selling": bool(whale_selling), + "retail_fomo": retail_fomo, + "buy_pressure": round(buy_pressure, 3), + } + + +def calculate_composite_score( + signals: Dict[str, Any], + weight_vwap: float = 15, + weight_donchian: float = 10, + weight_obv: float = 15, + weight_obi: float = 20, + weight_volume_spike: float = 10, + weight_trade_flow: float = 15, + weight_funding: float = 15, + obi_buy_threshold: float = 1.5, + obi_sell_threshold: float = 0.67, +) -> float: + """ + Compute weighted composite score from -100 (strong sell) to +100 (strong buy). + Only activated components contribute to total_weight, then score is normalised. + """ + score = 0.0 + total_weight = 0.0 + + # VWAP + if signals.get("vwap_above"): + score += weight_vwap + total_weight += weight_vwap + elif signals.get("vwap_below"): + score -= weight_vwap + total_weight += weight_vwap + + # Donchian breakout + if signals.get("donchian_breakout_up"): + score += weight_donchian + total_weight += weight_donchian + elif signals.get("donchian_breakout_down"): + score -= weight_donchian + total_weight += weight_donchian + + # OBV divergence + obv_div = signals.get("obv_divergence", "none") + if obv_div == "bullish": + score += weight_obv + total_weight += weight_obv + elif obv_div == "bearish": + score -= weight_obv + total_weight += weight_obv + + # OBI + obi = signals.get("obi") + if obi is not None: + if obi >= obi_buy_threshold: + score += weight_obi + total_weight += weight_obi + elif obi <= obi_sell_threshold: + score -= weight_obi + total_weight += weight_obi + + # Volume spike (directed by price trend) + if signals.get("volume_spike"): + price_trend = signals.get("price_trend", 0) + if price_trend > 0: + score += weight_volume_spike + elif price_trend < 0: + score -= weight_volume_spike + total_weight += weight_volume_spike + + # Whale activity + if signals.get("whale_buying"): + score += weight_trade_flow + total_weight += weight_trade_flow + elif signals.get("whale_selling"): + score -= weight_trade_flow + total_weight += weight_trade_flow + + # Funding rate contrarian (futures only) + funding_rate = signals.get("funding_rate") + if funding_rate is not None: + if funding_rate > 0.05: # too many longs β†’ contrarian short + score -= weight_funding + total_weight += weight_funding + elif funding_rate < -0.05: # too many shorts β†’ contrarian long + score += weight_funding + total_weight += weight_funding + + if total_weight > 0: + score = (score / total_weight) * 100 + + return round(score, 2) + + +# --------------------------------------------------------------------------- +# FULL ANALYSIS (for Condor wizard / analysis endpoint) +# --------------------------------------------------------------------------- + +def analyze_candles_for_anti_folla( + candles: List[Dict[str, Any]], + vwap_period: int = 20, + donchian_period: int = 20, + atr_period: int = 14, + obv_divergence_lookback: int = 10, + volume_spike_threshold: float = 2.5, + obi_buy_threshold: float = 1.5, + obi_sell_threshold: float = 0.67, + score_buy_threshold: float = 50.0, + score_sell_threshold: float = -50.0, + weight_vwap: float = 15, + weight_donchian: float = 10, + weight_obv: float = 15, + weight_obi: float = 20, + weight_volume_spike: float = 10, + weight_trade_flow: float = 15, + weight_funding: float = 15, +) -> Dict[str, Any]: + """ + Full Anti-Folla analysis from candle data. + + Returns a dict with: + - current_signal: 1 (BUY), -1 (SELL), 0 (NEUTRAL) + - composite_score: float -100..+100 + - vwap_current, donchian_upper_current, donchian_lower_current + - obv_divergence: 'bullish' | 'bearish' | 'none' + - volume_spike, volume_spike_multiplier + - whale_buying, whale_selling, retail_fomo, buy_pressure + - price_trend: pct change over last 20 candles + - suggested_score_buy_threshold, suggested_score_sell_threshold + - signal_count_long, signal_count_short (historical) + - analysis_candles + """ + result = { + "current_signal": 0, + "composite_score": 0.0, + "vwap_current": None, + "donchian_upper_current": None, + "donchian_lower_current": None, + "obv_divergence": "none", + "volume_spike": False, + "volume_spike_multiplier": 1.0, + "whale_buying": False, + "whale_selling": False, + "retail_fomo": False, + "buy_pressure": 0.5, + "price_trend": 0.0, + "suggested_score_buy_threshold": score_buy_threshold, + "suggested_score_sell_threshold": score_sell_threshold, + "signal_count_long": 0, + "signal_count_short": 0, + "analysis_candles": len(candles), + } + + min_required = max(vwap_period, donchian_period, atr_period, obv_divergence_lookback * 2, 50) + if not candles or len(candles) < min_required: + return result + + closes = [float(c.get("close") or c.get("c") or 0) for c in candles] + + # VWAP + vwap_series = calculate_rolling_vwap(candles, vwap_period) + current_price = closes[-1] + current_vwap = vwap_series[-1] if vwap_series else current_price + + # Donchian + donchian_upper, donchian_lower = calculate_donchian(candles, donchian_period) + current_upper = donchian_upper[-1] if donchian_upper else current_price + current_lower = donchian_lower[-1] if donchian_lower else current_price + + # OBV + obv_series = calculate_obv(candles) + obv_divergence = detect_obv_divergence(candles, obv_series, obv_divergence_lookback) + + # Volume spike + is_spike, spike_mult = detect_volume_spike(candles, volume_spike_threshold) + + # Trade flow + trade_flow = analyze_trade_flow(candles) + + # Price trend + lookback_pt = min(20, len(closes) - 1) + price_trend = (closes[-1] - closes[-lookback_pt - 1]) / closes[-lookback_pt - 1] if closes[-lookback_pt - 1] > 0 else 0.0 + + signals: Dict[str, Any] = { + "vwap_above": current_price > current_vwap, + "vwap_below": current_price < current_vwap, + "donchian_breakout_up": current_price > current_upper, + "donchian_breakout_down": current_price < current_lower, + "obv_divergence": obv_divergence, + "obi": None, # OBI requires live order book, not available from candles + "volume_spike": is_spike, + "price_trend": price_trend, + "funding_rate": None, # Requires live connector + **trade_flow, + } + + score = calculate_composite_score( + signals, + weight_vwap=weight_vwap, + weight_donchian=weight_donchian, + weight_obv=weight_obv, + weight_obi=weight_obi, + weight_volume_spike=weight_volume_spike, + weight_trade_flow=weight_trade_flow, + weight_funding=weight_funding, + obi_buy_threshold=obi_buy_threshold, + obi_sell_threshold=obi_sell_threshold, + ) + + if score >= score_buy_threshold: + current_signal = 1 + elif score <= score_sell_threshold: + current_signal = -1 + else: + current_signal = 0 + + # Historical signal count (rolling, no OBI/funding since those need live data) + long_count = 0 + short_count = 0 + for i in range(min_required, len(candles)): + sub = candles[:i + 1] + sub_closes = [float(c.get("close") or 0) for c in sub] + sub_vwap = calculate_rolling_vwap(sub, vwap_period) + sub_dup, sub_dlo = calculate_donchian(sub, donchian_period) + sub_obv = calculate_obv(sub) + sub_div = detect_obv_divergence(sub, sub_obv, obv_divergence_lookback) + sub_spike, _ = detect_volume_spike(sub, volume_spike_threshold) + sub_flow = analyze_trade_flow(sub) + sub_pt = (sub_closes[-1] - sub_closes[-min(20, len(sub_closes)-1)-1]) / sub_closes[-min(20, len(sub_closes)-1)-1] if len(sub_closes) > 1 else 0 + sub_price = sub_closes[-1] + sub_signals = { + "vwap_above": sub_price > (sub_vwap[-1] if sub_vwap else sub_price), + "vwap_below": sub_price < (sub_vwap[-1] if sub_vwap else sub_price), + "donchian_breakout_up": sub_price > (sub_dup[-1] if sub_dup else sub_price), + "donchian_breakout_down": sub_price < (sub_dlo[-1] if sub_dlo else sub_price), + "obv_divergence": sub_div, + "obi": None, + "volume_spike": sub_spike, + "price_trend": sub_pt, + "funding_rate": None, + **sub_flow, + } + s = calculate_composite_score(sub_signals, weight_vwap, weight_donchian, weight_obv, + weight_obi, weight_volume_spike, weight_trade_flow, weight_funding, + obi_buy_threshold, obi_sell_threshold) + if s >= score_buy_threshold: + long_count += 1 + elif s <= score_sell_threshold: + short_count += 1 + + result.update({ + "current_signal": current_signal, + "composite_score": score, + "vwap_current": round(current_vwap, 6), + "donchian_upper_current": round(current_upper, 6), + "donchian_lower_current": round(current_lower, 6), + "obv_divergence": obv_divergence, + "volume_spike": is_spike, + "volume_spike_multiplier": spike_mult, + "price_trend": round(price_trend * 100, 3), + "signal_count_long": long_count, + "signal_count_short": short_count, + **trade_flow, + }) + + return result + + +def format_anti_folla_analysis(analysis: Dict[str, Any]) -> str: + """Format analysis results for display in wizard final step.""" + lines = [] + n = analysis.get("analysis_candles", 0) + score = analysis.get("composite_score", 0.0) + signal = analysis.get("current_signal", 0) + signal_str = "🟒 BUY" if signal == 1 else ("πŸ”΄ SELL" if signal == -1 else "βšͺ NEUTRAL") + + lines.append(f"Anti-Folla analysis ({n} candles):") + lines.append(f" Signal now: {signal_str} | Score: {score:.1f}") + vwap = analysis.get("vwap_current") + dup = analysis.get("donchian_upper_current") + dlo = analysis.get("donchian_lower_current") + if vwap: + lines.append(f" VWAP: {vwap:.6g}") + if dup and dlo: + lines.append(f" Donchian: Upper={dup:.6g} Lower={dlo:.6g}") + lines.append(f" OBV divergence: {analysis.get('obv_divergence', 'none')}") + spike = analysis.get("volume_spike", False) + mult = analysis.get("volume_spike_multiplier", 1.0) + lines.append(f" Volume spike: {'YES' if spike else 'no'} ({mult:.1f}Γ—)") + lines.append(f" Whale buying: {analysis.get('whale_buying', False)} | Whale selling: {analysis.get('whale_selling', False)}") + lines.append(f" Retail FOMO: {analysis.get('retail_fomo', False)} | Buy pressure: {analysis.get('buy_pressure', 0.5):.1%}") + lines.append(f" Price trend (20c): {analysis.get('price_trend', 0.0):+.2f}%") + lines.append(f" Signals (history): LONG={analysis.get('signal_count_long', 0)} SHORT={analysis.get('signal_count_short', 0)}") + + return "\n".join(lines) + +def get_af_strategy_suggestions(analysis: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + """ + Ritorna suggerimenti per Anti-Folla V1 basati sull'analisi storica. + Modifica soglie di score e pesi per adattare la sensibilitΓ  del segnale. + """ + + # Suggerisci soglie basate sull'analisi storica + suggested_buy = analysis.get("suggested_score_buy_threshold", 50.0) + suggested_sell = analysis.get("suggested_score_sell_threshold", -50.0) + + # Valori base TP/SL (usati dal controller base) + base_tp = 0.03 + base_sl = 0.05 + base_ts_activation = 0.015 + base_ts_delta = 0.005 + + return { + "aggressive": { + "label": "Target: Aggressivo (Entrate anticipate)", + "score_buy_threshold": 30.0, # Soglia piΓΉ bassa β†’ piΓΉ BUY + "score_sell_threshold": -30.0, # Soglia piΓΉ alta β†’ piΓΉ SELL + # Pesi per lo score (piΓΉ bilanciati, meno peso a funding) + "weight_vwap": 15, + "weight_donchian": 15, # Aumentato per piΓΉ segnali breakout + "weight_obv": 15, + "weight_obi": 20, + "weight_volume_spike": 10, + "weight_trade_flow": 15, + "weight_funding": 10, # Ridotto (meno impatto funding) + "take_profit": round(base_tp * 0.7, 4), # TP piΓΉ stretto + "stop_loss": round(base_sl * 0.8, 4), # SL piΓΉ stretto + "trailing_stop_activation": round(base_ts_activation * 0.8, 4), + "trailing_stop_delta": round(base_ts_delta * 0.8, 4), + }, + "balanced": { + "label": "Target: Bilanciato (Standard)", + "score_buy_threshold": suggested_buy, + "score_sell_threshold": suggested_sell, + "weight_vwap": 15, + "weight_donchian": 10, + "weight_obv": 15, + "weight_obi": 20, + "weight_volume_spike": 10, + "weight_trade_flow": 15, + "weight_funding": 15, + "take_profit": base_tp, + "stop_loss": base_sl, + "trailing_stop_activation": base_ts_activation, + "trailing_stop_delta": base_ts_delta, + }, + "conservative": { + "label": "Target: Conservativo (Filtro stretto)", + "score_buy_threshold": 70.0, # Soglia piΓΉ alta β†’ meno BUY (solo segnali forti) + "score_sell_threshold": -70.0, # Soglia piΓΉ bassa β†’ meno SELL + # Pesi per lo score (piΓΉ peso a segnali confermati) + "weight_vwap": 20, # PiΓΉ peso al trend VWAP + "weight_donchian": 5, # Meno peso breakout (piΓΉ falsi) + "weight_obv": 20, # PiΓΉ peso divergenze OBV + "weight_obi": 25, # PiΓΉ peso OBI + "weight_volume_spike": 5, # Meno peso spike + "weight_trade_flow": 20, # PiΓΉ peso whale + "weight_funding": 5, # Poco peso funding + "take_profit": round(base_tp * 1.3, 4), # TP piΓΉ largo + "stop_loss": round(base_sl * 1.2, 4), # SL piΓΉ largo + "trailing_stop_activation": round(base_ts_activation * 1.2, 4), + "trailing_stop_delta": round(base_ts_delta * 1.2, 4), + } + } diff --git a/handlers/bots/controllers/anti_folla_v1/chart.py b/handlers/bots/controllers/anti_folla_v1/chart.py new file mode 100644 index 00000000..332b0fad --- /dev/null +++ b/handlers/bots/controllers/anti_folla_v1/chart.py @@ -0,0 +1,499 @@ +""" +Anti-Folla V1 chart generation. + +4 panels: + 1. Price – candlesticks + Rolling VWAP + Donchian Channel (upper/lower) + 2. Volume – colored bars; volume-spike candles highlighted in yellow + 3. OBV – On-Balance Volume; bullish/bearish divergence shaded + 4. Score – rolling composite score (-100…+100) computed from candle-only + signals (VWAP, Donchian, OBV divergence, Volume Spike, Trade + Flow). OBI and Funding Rate are excluded (require live data). + score_buy_threshold and score_sell_threshold shown as dashed + lines; BUY/SELL zones shaded. + +Signal logic: + BUY when composite_score >= score_buy_threshold + SELL when composite_score <= score_sell_threshold +""" + +import io +import time +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +from matplotlib.patches import Rectangle + + +# ── PUBLIC API ─────────────────────────────────────────────────────── +def generate_chart(config, candles_data, current_price=None, **kwargs): + if not candles_data or len(candles_data) < 10: + return _generate_simple_chart(candles_data, current_price) + + df = _prepare_dataframe(candles_data) + full_df = df.copy() + MAX_VISIBLE_CANDLES = 96 + for col in ['open', 'high', 'low', 'close', 'volume']: + full_df[col] = pd.to_numeric(full_df.get(col, 0), errors='coerce').fillna(0) + + # ── CONFIG ─────────────────────────────────────────────────────── + vwap_period = int(config.get('vwap_period', 20)) + donchian_period = int(config.get('donchian_period', 20)) + vol_spike_thr = float(config.get('volume_spike_threshold', 2.5)) + score_buy_thr = float(config.get('score_buy_threshold', 50.0)) + score_sell_thr = float(config.get('score_sell_threshold', -50.0)) + w_vwap = float(config.get('weight_vwap', 15)) + w_donchian = float(config.get('weight_donchian', 10)) + w_obv = float(config.get('weight_obv', 15)) + w_vol_spike = float(config.get('weight_volume_spike', 10)) + w_trade_flow = float(config.get('weight_trade_flow', 15)) + obv_lookback = int(config.get('obv_divergence_lookback', 10)) + + # ── INDICATORI ─────────────────────────────────────────────────── + # Rolling VWAP + pv = full_df['close'] * full_df['volume'] + full_df['vwap'] = (pv.rolling(vwap_period).sum() / full_df['volume'].rolling(vwap_period).sum()) + + # Donchian Channel (shift=1 β†’ excludes current candle) + full_df['don_upper'] = full_df['high'].shift(1).rolling(donchian_period).max() + full_df['don_lower'] = full_df['low'].shift(1).rolling(donchian_period).min() + + # OBV + full_df['obv'] = _calc_obv(full_df) + + # Volume spike: current vol vs 20-candle rolling avg (shifted by 1) + full_df['vol_avg'] = ( + full_df['volume'] + .shift(1) + .rolling(20) + .mean() + ) + + full_df['vol_ratio'] = ( + full_df['volume'] / + full_df['vol_avg'].replace(0, np.nan) + ).fillna(1.0) + full_df['is_spike'] = full_df['vol_ratio'] >= vol_spike_thr + + # Rolling composite score (candle-only signals) + full_df['score'] = _calc_rolling_score( + full_df, vwap_period, donchian_period, obv_lookback, + vol_spike_thr, w_vwap, w_donchian, w_obv, w_vol_spike, w_trade_flow + ) + # SOLO DOPO TAGLI + df = full_df.tail(MAX_VISIBLE_CANDLES).copy() + vol_avg = df['vol_avg'] + # ── FIGURA ─────────────────────────────────────────────────────── + fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1, figsize=(22, 14), sharex=True, gridspec_kw={ + 'height_ratios': [4.5, 1.2, 1.3, 1.5] + } + ) + + fig.patch.set_facecolor('#111111') + for ax in [ax1, ax2, ax3, ax4]: + ax.set_facecolor('#111111') + ax.tick_params(colors='white') + ax.yaxis.label.set_color('white') + ax.spines['bottom'].set_color('#444') + ax.spines['top'].set_color('#444') + ax.spines['left'].set_color('#444') + ax.spines['right'].set_color('#444') + dates = mdates.date2num(df['datetime']) + + if len(dates) > 1: + + interval = config.get('interval', '5m') + + if interval in ['1m', '5m']: + width_factor = 0.65 + else: + width_factor = 0.85 + + candle_width = (dates[1] - dates[0]) * width_factor + volume_width = (dates[1] - dates[0]) * width_factor + else: + candle_width = volume_width = 0.0005 + + # ── PANNELLO 1: PREZZO + VWAP + DONCHIAN ───────────────────────── + for i in range(len(df)): + o, h, l, c = df.iloc[i][['open', 'high', 'low', 'close']] + color = '#2ecc71' if c >= o else '#e74c3c' + ax1.plot([dates[i], dates[i]], [l, h], color=color, linewidth=1) + ax1.add_patch(Rectangle( + (dates[i] - candle_width / 2, min(o, c)), + candle_width, abs(c - o) or 1e-8, color=color + )) + + # Donchian band + valid_don = df['don_upper'].notna() & df['don_lower'].notna() + ax1.fill_between(df['datetime'], df['don_lower'], df['don_upper'], where=valid_don, alpha=0.14, color='#3498db', label='Donchian') + ax1.plot(df['datetime'], df['don_upper'], linewidth=0.8, color='#3498db', linestyle='--', alpha=0.7) + ax1.plot(df['datetime'], df['don_lower'], linewidth=0.8, color='#3498db', linestyle='--', alpha=0.7) + + # VWAP + ax1.plot(df['datetime'], df['vwap'], linewidth=1.5, color='#f39c12', label=f'VWAP({vwap_period})') + + if current_price is not None: + ax1.axhline(y=current_price, linestyle='--', alpha=0.6, color='gold') + + legend1 = ax1.legend(loc='upper left',fontsize=9,ncol=3,framealpha=0) + for text in legend1.get_texts(): + text.set_color('white') + ax1.set_ylabel('Price') + ax1.set_xlim(df['datetime'].min(), df['datetime'].max()) + + # ── PANNELLO 2: VOLUME (spike in giallo) ───────────────────────── + vol_colors = np.where( + df['is_spike'], + np.where( + df['close'] >= df['open'], + '#7DFFB3', # spike bullish + '#FF9B9B' # spike bearish + ), + np.where( + df['close'] >= df['open'], + '#2ecc71', + '#e74c3c' + ) + ) + + ax2.bar( + dates, + df['volume'], + width=volume_width, + color=vol_colors, + alpha=0.8 + ) + + # Linea media volume (riferimento spike) + ax2.plot(df['datetime'], vol_avg, linewidth=1, color='white', linestyle=':', alpha=0.6, label='Avg vol') + ax2.plot(df['datetime'], vol_avg * vol_spike_thr, linewidth=1, color='#f39c12', linestyle='--', alpha=0.7, + label=f'Spike Γ—{vol_spike_thr}') + + legend2 = ax2.legend(loc='upper left', fontsize=9, framealpha=0) + for text in legend2.get_texts(): + text.set_color('white') + ax2.set_ylabel('Volume') + + # ── PANNELLO 3: OBV ────────────────────────────────────────────── + # colora il fill per evidenziare divergenze OBV/prezzo + ax3.plot(df['datetime'], df['obv'], linewidth=1.3, + color='#9b59b6', label='OBV') + + # Divergenza rolling semplice: se prezzo sale e OBV scende β†’ bearish (rosso) + price_trend = df['close'].diff(obv_lookback) + obv_trend = df['obv'].diff(obv_lookback) + bull_div = ((price_trend < 0) & (obv_trend > 0) & df['obv'].notna()) + bear_div = ((price_trend > 0) & (obv_trend < 0) & df['obv'].notna()) + + ax3.fill_between(df['datetime'], df['obv'], + where=bull_div, alpha=0.25, color='#2ecc71', + label='Bullish div') + ax3.fill_between(df['datetime'], df['obv'], + where=bear_div, alpha=0.25, color='#e74c3c', + label='Bearish div') + + legend3 = ax3.legend(loc='upper left', fontsize=9, ncol=3, framealpha=0) + for text in legend3.get_texts(): + text.set_color('white') + ax3.set_ylabel('OBV') + + # ── PANNELLO 4: SCORE COMPOSITO ────────────────────────────────── + score_vals = df['score'].values + + # Colora la linea: verde se > 0, rosso se < 0 + for i in range(1, len(df)): + if np.isnan(score_vals[i]) or np.isnan(score_vals[i - 1]): + continue + seg_color = '#2ecc71' if score_vals[i] >= 0 else '#e74c3c' + ax4.plot( + [df['datetime'].iloc[i - 1], df['datetime'].iloc[i]], + [score_vals[i - 1], score_vals[i]], + color=seg_color, linewidth=1.3 + ) + + # Soglie e zone + ax4.axhline(score_buy_thr, linestyle='--', color='#2ecc71', linewidth=1.2, + label=f'Buy β‰₯{score_buy_thr:.0f}') + ax4.axhline(score_sell_thr, linestyle='--', color='#e74c3c', linewidth=1.2, + label=f'Sell ≀{score_sell_thr:.0f}') + ax4.axhline(0, linestyle=':', color='gray', alpha=0.5) + ax4.fill_between( + df['datetime'], + 0, + score_vals, + where=score_vals >= 0, + alpha=0.08, + color='#2ecc71' + ) + + ax4.fill_between( + df['datetime'], + 0, + score_vals, + where=score_vals < 0, + alpha=0.08, + color='#e74c3c' + ) + + ax4.set_ylim(-105, 105) + ax4.set_ylabel('Score') + legend4 = ax4.legend(loc='upper left', fontsize=9, ncol=4, framealpha=0) + for text in legend4.get_texts(): + text.set_color('white') + +# ── FIX ASSE X BASATO SUL TIMEFRAME ─────────────────────────────── + + interval = config.get('interval', '5m') + + if interval == '1m': + locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45]) + formatter = mdates.DateFormatter('%H:%M') + minor_locator = mdates.MinuteLocator(interval=5) + + elif interval == '5m': + locator = mdates.HourLocator(interval=1) + formatter = mdates.DateFormatter('%H:%M') + minor_locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45]) + + elif interval == '15m': + + locator = mdates.HourLocator(interval=3) + formatter = mdates.DateFormatter('%d %b - %H:%M') + minor_locator = mdates.HourLocator(interval=1) + + elif interval == '1h': + + locator = mdates.HourLocator(interval=12) + formatter = mdates.DateFormatter('%d %b - %H:%M') + minor_locator = mdates.HourLocator(interval=3) + + elif interval == '8h': + + locator = mdates.DayLocator(interval=4) + formatter = mdates.DateFormatter('%b%d') + minor_locator = mdates.DayLocator(interval=1) + + else: + + locator = mdates.AutoDateLocator(minticks=6, maxticks=12) + formatter = mdates.ConciseDateFormatter(locator) + minor_locator = None + + # ── APPLICA A TUTTI GLI ASSI ────────────────────────────────────── + + ax1.tick_params(labelbottom=False) + ax2.tick_params(labelbottom=False) + ax3.tick_params(labelbottom=False) + + for ax in [ax1, ax2, ax3, ax4]: + + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(formatter) + + if minor_locator and interval not in ['1m', '5m']: + ax.xaxis.set_minor_locator(minor_locator) + + plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center') + + # grid verticale + ax.grid(True, which='major', axis='x', linestyle='--', alpha=0.15) + + # grid orizzontale + ax.grid(True, which='major', axis='y', alpha=0.25) + + # minor grid + if interval not in ['1m', '5m']: + ax.grid(True, which='minor', axis='x', linestyle=':', alpha=0.05) + +# ── TITOLO ─────────────────────────────────────────────────────── + interval = config.get('interval', '5m') + fig.suptitle( + f"{config.get('trading_pair', 'Unknown')} - Anti-Folla V1 " + f"(VWAP{vwap_period} | Don{donchian_period} | {interval})", + fontsize=13 + ) + + plt.subplots_adjust(hspace=0.05, top=0.94, bottom=0.06) + + buf = io.BytesIO() + plt.savefig(buf, format='png', dpi=120, bbox_inches='tight') + plt.close(fig) + buf.seek(0) + return buf + + +def generate_preview_chart(config, candles_data, current_price=None, **kwargs): + return generate_chart(config, candles_data, current_price) + + +# ── HELPERS ────────────────────────────────────────────────────────── +def _prepare_dataframe(candles, timezone=None): + if timezone is None: + # Prende il fuso orario del sistema + timezone = time.tzname[0] + df = pd.DataFrame(candles) + + # Cerca colonna timestamp + ts_col = next((c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), None) + + if ts_col: + # Converti timestamp + sample = df[ts_col].iloc[0] + if isinstance(sample, (int, float)): + # Determina se Γ¨ millisecondi o secondi + if sample > 10**12: # nanosecondi + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='ns', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + elif sample > 10**10: # millisecondi (dopo il 1970) + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='ms', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + else: # secondi + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='s', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + else: + df['datetime'] = (pd.to_datetime(df[ts_col], utc=True).dt.tz_convert(timezone).dt.tz_localize(None)) + else: + # Fallback: crea date sequenziali usando l'intervallo dalla config + # NOTA: questo Γ¨ un fallback, idealmente dovresti avere timestamp reali + freq = config.get('interval', '5m') if 'config' in locals() else '5m' + df['datetime'] = pd.date_range( + end=pd.Timestamp.now(), + periods=len(df), + freq=freq + ) + + return df.sort_values('datetime').reset_index(drop=True) + +def _calc_obv(df: pd.DataFrame) -> pd.Series: + """On-Balance Volume.""" + direction = np.sign(df['close'].diff().fillna(0)) + return (direction * df['volume']).cumsum() + + +def _calc_rolling_score( + df: pd.DataFrame, + vwap_period: int, + donchian_period: int, + obv_lookback: int, + vol_spike_thr: float, + w_vwap: float, + w_donchian: float, + w_obv: float, + w_vol_spike: float, + w_trade_flow: float, +) -> pd.Series: + """ + Vectorised rolling composite score (candle-only signals). + OBI and Funding Rate are excluded (require live data). + Active weights are re-normalised to 100 each bar. + + Components: + VWAP β†’ +w if close > vwap, -w if close < vwap + Donchian β†’ +w if close > don_upper (breakout up), + -w if close < don_lower (breakout down) + OBV div β†’ +w if bullish div, -w if bearish div + Volume spikeβ†’ +w if spike AND bullish candle, -w if spike AND bearish + Trade flow β†’ Β±w scaled by buy_pressure centred on 0.5 + """ + close = df['close'] + open_ = df['open'] + vwap = df['vwap'] + don_upper = df['don_upper'] + don_lower = df['don_lower'] + obv = df['obv'] + volume = df['volume'] + is_spike = df['is_spike'] + + n = len(df) + scores = np.full(n, np.nan) + + # Pre-compute rolling buy_pressure (10-bar window) + tf_window = 10 + bull_mask = close >= open_ + + bull_vol = (volume.where(bull_mask, 0).rolling(tf_window + 1).sum()) + bear_vol = (volume.where(~bull_mask, 0).rolling(tf_window + 1).sum()) + total_vol = (bull_vol + bear_vol).replace(0, np.nan) + buy_pressure = (bull_vol / total_vol).fillna(0.5) + + # OBV trend + price_trend = close.diff(obv_lookback) + obv_trend = obv.diff(obv_lookback) + + start = max(vwap_period, donchian_period, obv_lookback) - 1 + + for i in range(start, n): + score = 0.0 + active = 0.0 + + # VWAP signal + if not (np.isnan(vwap.iloc[i])): + active += w_vwap + score += w_vwap if close.iloc[i] > vwap.iloc[i] else -w_vwap + + # Donchian breakout + if not (np.isnan(don_upper.iloc[i]) or np.isnan(don_lower.iloc[i])): + if close.iloc[i] > don_upper.iloc[i]: + score += w_donchian + active += w_donchian + elif close.iloc[i] < don_lower.iloc[i]: + score -= w_donchian + active += w_donchian + + # OBV divergence + if not (np.isnan(price_trend.iloc[i]) or np.isnan(obv_trend.iloc[i])): + if price_trend.iloc[i] < 0 and obv_trend.iloc[i] > 0: + score += w_obv # bullish divergence + active += w_obv + elif price_trend.iloc[i] > 0 and obv_trend.iloc[i] < 0: + score -= w_obv # bearish divergence + active += w_obv + + # Volume spike + if is_spike.iloc[i]: + bull_candle = close.iloc[i] >= open_.iloc[i] + score += w_vol_spike if bull_candle else -w_vol_spike + active += w_vol_spike + + # Trade flow (buy pressure centred on 0.5, scaled to Β±1) + bp = buy_pressure.iloc[i] + tf_contribution = (bp - 0.5) * 2 * w_trade_flow # range: -w … +w + score += tf_contribution + active += w_trade_flow + + # Normalise to -100…+100 based on active weights + if active > 0: + scores[i] = round((score / active) * 100, 2) + + return pd.Series(scores, index=df.index) + +def _generate_simple_chart(candles_data, current_price): + if not candles_data: + return io.BytesIO() + df = _prepare_dataframe(candles_data) + MAX_VISIBLE_CANDLES = 96 + if len(df) > MAX_VISIBLE_CANDLES: + df = df.tail(MAX_VISIBLE_CANDLES).reset_index(drop=True) + fig, ax = plt.subplots(figsize=(10, 6)) + ax.plot(df['datetime'], pd.to_numeric(df.get('close', pd.Series(dtype=float)), errors='coerce')) + if current_price is not None: + ax.axhline(y=current_price, linestyle='--') + locator = mdates.AutoDateLocator(minticks=6, maxticks=12) + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator)) + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format='png') + plt.close(fig) + buf.seek(0) + return buf diff --git a/handlers/bots/controllers/anti_folla_v1/config.py b/handlers/bots/controllers/anti_folla_v1/config.py new file mode 100644 index 00000000..1f9109eb --- /dev/null +++ b/handlers/bots/controllers/anti_folla_v1/config.py @@ -0,0 +1,202 @@ +""" +Anti-Folla V1 controller configuration. + +Crowd-contrarian directional trading strategy using real flow parameters: +- LONG when composite score >= score_buy_threshold +- SHORT when composite score <= score_sell_threshold + +Score is a weighted composite of: + VWAP position, Donchian breakout, OBV divergence, Order Book Imbalance, + Volume Spike, Trade Flow (whale activity), Funding Rate (futures only). +""" + +from typing import Any, Dict, List, Optional, Tuple + +from .._base import ControllerField + +DEFAULTS: Dict[str, Any] = { + "controller_name": "anti_folla_v1", + "controller_type": "directional_trading", + "id": "", + # Base fields + "manual_kill_switch": None, + "candles_config": [], + # Connector + "connector_name": "", + "trading_pair": "", + "total_amount_quote": 1000, + "leverage": 1, + "position_mode": "HEDGE", + # DirectionalTradingControllerConfigBase fields + "max_executors_per_side": 1, + "cooldown_time": 60, + "stop_loss": 0.05, + "take_profit": 0.03, + "take_profit_order_type": 2, + "time_limit": None, + # Trailing stop + "trailing_stop": { + "activation_price": 0.015, + "trailing_delta": 0.005, + }, + # Candles config + "candles_connector": "", + "candles_trading_pair": "", + "interval": "5m", + # Futures flag + "is_perpetual": False, + # Anti-Folla parameters + "vwap_period": 20, + "donchian_period": 20, + "atr_period": 14, + "obv_divergence_lookback": 10, + "volume_spike_threshold": 2.5, + # Order Book Imbalance + "enable_order_book_imbalance": True, + "obi_depth_percentage": 0.02, + "obi_buy_threshold": 1.5, + "obi_sell_threshold": 0.67, + # Score thresholds + "score_buy_threshold": 50.0, + "score_sell_threshold": -50.0, + # Weights (must sum to 100) + "weight_vwap": 15, + "weight_donchian": 10, + "weight_obv": 15, + "weight_obi": 20, + "weight_volume_spike": 10, + "weight_trade_flow": 15, + "weight_funding": 15, +} + +FIELDS: Dict[str, ControllerField] = { + "id": ControllerField(name="id", label="Config ID", type="str", required=True, hint="Auto-generated"), + "connector_name": ControllerField(name="connector_name", label="Connector", type="str", required=True, hint="Exchange connector"), + "trading_pair": ControllerField(name="trading_pair", label="Trading Pair", type="str", required=True, hint="e.g. BTC-USDT"), + "leverage": ControllerField(name="leverage", label="Leverage", type="int", required=True, hint="e.g. 1, 5, 10", default=1), + "position_mode": ControllerField(name="position_mode", label="Position Mode", type="str", required=False, hint="HEDGE or ONEWAY", default="HEDGE"), + "total_amount_quote": ControllerField(name="total_amount_quote", label="Total Amount (Quote)", type="float", required=True, hint="e.g. 1000 USDT"), + "max_executors_per_side": ControllerField(name="max_executors_per_side", label="Max Executors/Side", type="int", required=False, hint="Max concurrent positions per side", default=1), + "cooldown_time": ControllerField(name="cooldown_time", label="Cooldown Time (s)", type="int", required=False, hint="Seconds between new executors", default=60), + "stop_loss": ControllerField(name="stop_loss", label="Stop Loss", type="float", required=False, hint="e.g. 0.05 = 5%", default=0.05), + "take_profit": ControllerField(name="take_profit", label="Take Profit", type="float", required=False, hint="e.g. 0.03 = 3%", default=0.03), + "take_profit_order_type": ControllerField(name="take_profit_order_type", label="TP Order Type", type="int", required=False, hint="1=Market, 2=Limit, 3=Limit Maker", default=2), + "time_limit": ControllerField(name="time_limit", label="Time Limit (s)", type="int", required=False, hint="Max executor lifetime (None = no limit)", default=None), + "candles_connector": ControllerField(name="candles_connector", label="Candles Connector", type="str", required=False, hint="Leave empty to use same as connector", default=""), + "candles_trading_pair": ControllerField(name="candles_trading_pair", label="Candles Pair", type="str", required=False, hint="Leave empty to use same as trading pair", default=""), + "interval": ControllerField(name="interval", label="Candle Interval", type="str", required=True, hint="e.g. 1m, 5m, 1h, 8h", default="5m"), + "is_perpetual": ControllerField(name="is_perpetual", label="Is Perpetual/Futures", type="bool", required=False, hint="Enable funding rate signal (True for perp/futures)", default=False), + # Anti-Folla parameters + "vwap_period": ControllerField(name="vwap_period", label="VWAP Period", type="int", required=False, hint="Rolling VWAP window", default=20), + "donchian_period": ControllerField(name="donchian_period", label="Donchian Period", type="int", required=False, hint="Donchian Channel period (with shift)", default=20), + "atr_period": ControllerField(name="atr_period", label="ATR Period", type="int", required=False, hint="ATR period", default=14), + "obv_divergence_lookback": ControllerField(name="obv_divergence_lookback", label="OBV Lookback", type="int", required=False, hint="Lookback for OBV divergence detection", default=10), + "volume_spike_threshold": ControllerField(name="volume_spike_threshold", label="Volume Spike Threshold", type="float", required=False, hint="Volume multiplier to detect spike (e.g. 2.5 = 2.5x avg)", default=2.5), + "enable_order_book_imbalance": ControllerField(name="enable_order_book_imbalance", label="Enable OBI", type="bool", required=False, hint="Enable Order Book Imbalance analysis", default=True), + "obi_depth_percentage": ControllerField(name="obi_depth_percentage", label="OBI Depth %", type="float", required=False, hint="Price depth from best bid for OBI (e.g. 0.02 = 2%)", default=0.02), + "obi_buy_threshold": ControllerField(name="obi_buy_threshold", label="OBI Buy Threshold", type="float", required=False, hint="OBI ratio >= this β†’ buy pressure (default 1.5)", default=1.5), + "obi_sell_threshold": ControllerField(name="obi_sell_threshold", label="OBI Sell Threshold", type="float", required=False, hint="OBI ratio <= this β†’ sell pressure (default 0.67)", default=0.67), + "score_buy_threshold": ControllerField(name="score_buy_threshold", label="Score BUY Threshold", type="float", required=False, hint="Min composite score to trigger BUY (default 50)", default=50.0), + "score_sell_threshold": ControllerField(name="score_sell_threshold", label="Score SELL Threshold", type="float", required=False, hint="Max composite score to trigger SELL (default -50)", default=-50.0), + # Weights + "weight_vwap": ControllerField(name="weight_vwap", label="Weight VWAP", type="float", required=False, hint="Weight for VWAP signal (all weights must sum to 100)", default=15), + "weight_donchian": ControllerField(name="weight_donchian", label="Weight Donchian", type="float", required=False, hint="Weight for Donchian breakout signal", default=10), + "weight_obv": ControllerField(name="weight_obv", label="Weight OBV", type="float", required=False, hint="Weight for OBV divergence signal", default=15), + "weight_obi": ControllerField(name="weight_obi", label="Weight OBI", type="float", required=False, hint="Weight for Order Book Imbalance signal", default=20), + "weight_volume_spike": ControllerField(name="weight_volume_spike", label="Weight Volume Spike", type="float", required=False, hint="Weight for volume spike signal", default=10), + "weight_trade_flow": ControllerField(name="weight_trade_flow", label="Weight Trade Flow", type="float", required=False, hint="Weight for whale trade flow signal", default=15), + "weight_funding": ControllerField(name="weight_funding", label="Weight Funding Rate", type="float", required=False, hint="Weight for funding rate contrarian signal (futures only)", default=15), + "manual_kill_switch": ControllerField(name="manual_kill_switch", label="Kill Switch", type="bool", required=False, hint="Manual kill switch", default=None), +} + +FIELD_ORDER: List[str] = [ + "id", "connector_name", "trading_pair", "leverage", "position_mode", + "total_amount_quote", "max_executors_per_side", "cooldown_time", + "stop_loss", "take_profit", "take_profit_order_type", "time_limit", + "candles_connector", "candles_trading_pair", "interval", + "is_perpetual", + "vwap_period", "donchian_period", "atr_period", + "obv_divergence_lookback", "volume_spike_threshold", + "enable_order_book_imbalance", "obi_depth_percentage", + "obi_buy_threshold", "obi_sell_threshold", + "score_buy_threshold", "score_sell_threshold", + "weight_vwap", "weight_donchian", "weight_obv", "weight_obi", + "weight_volume_spike", "weight_trade_flow", "weight_funding", + "manual_kill_switch", +] + +EDITABLE_FIELDS: List[str] = [ + "connector_name", "trading_pair", "total_amount_quote", "leverage", + "max_executors_per_side", "cooldown_time", + "stop_loss", "take_profit", "take_profit_order_type", + "trailing_stop_activation", "trailing_stop_delta", + "candles_connector", "candles_trading_pair", "interval", + "is_perpetual", + "vwap_period", "donchian_period", "atr_period", + "obv_divergence_lookback", "volume_spike_threshold", + "enable_order_book_imbalance", "obi_depth_percentage", + "obi_buy_threshold", "obi_sell_threshold", + "score_buy_threshold", "score_sell_threshold", + "weight_vwap", "weight_donchian", "weight_obv", "weight_obi", + "weight_volume_spike", "weight_trade_flow", "weight_funding", +] + +def get_flat_fields(config: Dict[str, Any]) -> Dict[str, Any]: + """Estrae i campi in formato piatto per l'editing, gestendo trailing_stop.""" + trailing = config.get("trailing_stop", {}) + # Copia i campi esistenti (che sono giΓ  piatti) + flat = dict(config) + # Aggiungi i due campi virtuali + flat["trailing_stop_activation"] = trailing.get("activation_price", 0.015) + flat["trailing_stop_delta"] = trailing.get("trailing_delta", 0.005) + # Rimuovi il dizionario originale per non mostrarlo come campo separato + flat.pop("trailing_stop", None) + return flat + + +def apply_flat_fields(config: Dict[str, Any], updates: Dict[str, Any]) -> None: + """Applica gli aggiornamenti, riconvertendo trailing_stop_activation/delta.""" + for key, value in updates.items(): + if key == "trailing_stop_activation": + if "trailing_stop" not in config: + config["trailing_stop"] = {} + config["trailing_stop"]["activation_price"] = value + elif key == "trailing_stop_delta": + if "trailing_stop" not in config: + config["trailing_stop"] = {} + config["trailing_stop"]["trailing_delta"] = value + else: + config[key] = value + +def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + required = ["connector_name", "trading_pair", "total_amount_quote"] + for field in required: + if not config.get(field): + return False, f"Missing required field: {field}" + + weights = [ + config.get("weight_vwap", 15), + config.get("weight_donchian", 10), + config.get("weight_obv", 15), + config.get("weight_obi", 20), + config.get("weight_volume_spike", 10), + config.get("weight_trade_flow", 15), + config.get("weight_funding", 15), + ] + total = sum(weights) + if abs(total - 100.0) > 0.01: + return False, f"Weights must sum to 100, current total: {total:.2f}" + + return True, None + + +def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + max_num = 0 + for cfg in existing_configs: + parts = cfg.get("id", "").split("_", 1) + if parts and parts[0].isdigit(): + max_num = max(max_num, int(parts[0])) + seq = str(max_num + 1).zfill(3) + connector = config.get("connector_name", "unknown").replace("_perpetual", "").replace("_spot", "") + pair = config.get("trading_pair", "UNKNOWN").upper() + return f"{seq}_antifolla_{connector}_{pair}" diff --git a/handlers/bots/controllers/arbitrage_controller/__init__.py b/handlers/bots/controllers/arbitrage_controller/__init__.py new file mode 100644 index 00000000..01b0c7d4 --- /dev/null +++ b/handlers/bots/controllers/arbitrage_controller/__init__.py @@ -0,0 +1,88 @@ +"""Arbitrage Controller Module - CEX/DEX arbitrage with historical analysis.""" + +import io +from typing import Any, Dict, List, Optional, Tuple + +from .._base import BaseController, ControllerField +from .chart import generate_chart, generate_preview_chart +from .config import ( + DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, + generate_id, validate_config +) +from .analysis import analyze_historical_spread, ArbitrageAnalyzer + + +class ArbitrageControllerController(BaseController): + controller_type = "arbitrage_controller" + display_name = "Arbitrage" + description = "CEX/DEX arbitrage with historical spread analysis" + + @classmethod + def get_defaults(cls) -> Dict[str, Any]: + return DEFAULTS.copy() + + @classmethod + def get_fields(cls) -> Dict[str, ControllerField]: + return FIELDS + + @classmethod + def get_field_order(cls) -> List[str]: + return FIELD_ORDER + + @classmethod + def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + """Validate configuration synchronously.""" + return validate_config(config) + + @classmethod + def generate_chart( + cls, + config: Dict[str, Any], + candles_data: List[Dict[str, Any]], + current_price: Optional[float] = None, + grid_analysis: Optional[Dict[str, Any]] = None + ) -> io.BytesIO: + """Generate chart with optional grid analysis overlay.""" + return generate_chart(config, candles_data, current_price, grid_analysis) + + @classmethod + def generate_preview_chart( + cls, + config: Dict[str, Any], + candles_data: List[Dict[str, Any]], + current_price: Optional[float] = None + ) -> io.BytesIO: + """Generate preview chart.""" + return generate_preview_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 for the configuration.""" + return generate_id(config, existing_configs) + + @classmethod + async def analyze_historical_spread( + cls, + candles1: List[Dict[str, Any]], + candles2: List[Dict[str, Any]], + config: Dict[str, Any], + fee_1: Optional[float] = None, + fee_2: Optional[float] = None + ) -> Dict[str, Any]: + """Run historical spread analysis for fee and profitability assessment.""" + return await analyze_historical_spread(candles1, candles2, config, fee_1, fee_2) + + +__all__ = [ + "ArbitrageControllerController", + "DEFAULTS", + "FIELDS", + "FIELD_ORDER", + "EDITABLE_FIELDS", + "validate_config", + "generate_id", + "generate_chart", + "generate_preview_chart", + "analyze_historical_spread", + "ArbitrageAnalyzer" +] diff --git a/handlers/bots/controllers/arbitrage_controller/analysis.py b/handlers/bots/controllers/arbitrage_controller/analysis.py new file mode 100644 index 00000000..44326b3c --- /dev/null +++ b/handlers/bots/controllers/arbitrage_controller/analysis.py @@ -0,0 +1,121 @@ +""" +Analysis for arbitrage controller - historical spread analysis and fee assessment. +""" + +import logging +from typing import Any, Dict, List, Optional + +import numpy as np + +logger = logging.getLogger(__name__) + + +class ArbitrageAnalyzer: + """ + Analyzes historical spread data to suggest optimal parameters. + """ + + def __init__(self, config: Dict[str, Any]): + self.config = config + self.spread_values = [] + self.zscore_values = [] + + def load_spread_data(self, spread_data: List[Dict]) -> None: + """Load spread data from historical analysis.""" + self.spread_values = [s["spread"] for s in spread_data if not np.isnan(s["spread"])] + self.zscore_values = [s["zscore"] for s in spread_data if not np.isnan(s["zscore"])] + + def suggest_min_profitability(self, fee_total: float = 0.002, percentile: float = 75) -> float: + if not self.spread_values: + return max(fee_total, 0.005) + + spread_sorted = sorted(self.spread_values) + + # Se P75 Γ¨ negativo, usa un percentile piΓΉ alto (es. P90) + p75_idx = int(len(spread_sorted) * 75 / 100) + p75_val = spread_sorted[p75_idx] + + if p75_val <= 0: + # Usa P90 invece di P75 + p90_idx = int(len(spread_sorted) * 90 / 100) + suggested_pct = spread_sorted[p90_idx] + logger.info(f"P75 negativo ({p75_val:.4f}%), usando P90 = {suggested_pct:.4f}%") + else: + suggested_pct = p75_val + + suggested_decimal = suggested_pct / 100 + suggested_decimal = max(suggested_decimal, fee_total * 1.1) + + return suggested_decimal + + def get_spread_statistics(self) -> Dict[str, float]: + """Get comprehensive spread statistics.""" + if not self.spread_values: + return {} + + return { + "min": min(self.spread_values), + "max": max(self.spread_values), + "mean": np.mean(self.spread_values), + "median": np.median(self.spread_values), + "std": np.std(self.spread_values), + "p25": np.percentile(self.spread_values, 25), + "p50": np.percentile(self.spread_values, 50), + "p75": np.percentile(self.spread_values, 75), + "p90": np.percentile(self.spread_values, 90), + "p95": np.percentile(self.spread_values, 95), + } + + +async def analyze_historical_spread( + candles1: List[Dict], + candles2: List[Dict], + config: Dict[str, Any], + fee_1: float = None, + fee_2: float = None +) -> Dict[str, Any]: + """ + Run historical spread analysis and return statistics. + + Args: + candles1: Historical candles for exchange 1 + candles2: Historical candles for exchange 2 + config: Configuration dict + fee_1: Fee rate for exchange 1 (optional) + fee_2: Fee rate for exchange 2 (optional) + + Returns: + Dict with statistics and suggestions + """ + from .chart import calculate_spread_series + + # Calcola spread storico + spread_data = calculate_spread_series(candles1, candles2) + + if not spread_data: + return {"error": "No spread data available"} + + # Fee totali per round trip + total_fee = (fee_1 or 0.001) + (fee_2 or 0.001) + + # Analizza + analyzer = ArbitrageAnalyzer(config) + analyzer.load_spread_data(spread_data) + + statistics = analyzer.get_spread_statistics() + suggested_min_profitability = analyzer.suggest_min_profitability( + fee_total=total_fee, + percentile=75 + ) + + # Calcola se la coppia Γ¨ arbitraggiabile (P75 > fees) + p75 = statistics.get('p75', 0) + is_arbitrageable = (p75 / 100) > total_fee if p75 > 0 else False + + return { + "statistics": statistics, + "suggested_min_profitability": suggested_min_profitability, + "total_fees_percent": total_fee * 100, + "is_arbitrageable": is_arbitrageable, + "total_samples": len(analyzer.spread_values) + } diff --git a/handlers/bots/controllers/arbitrage_controller/chart.py b/handlers/bots/controllers/arbitrage_controller/chart.py new file mode 100644 index 00000000..506dff62 --- /dev/null +++ b/handlers/bots/controllers/arbitrage_controller/chart.py @@ -0,0 +1,535 @@ +"""Arbitrage Controller chart - shows both exchange prices, spread % and Z-score with profit simulation.""" + +import io +from typing import Any, Dict, List, Optional +import logging +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from plotly.subplots import make_subplots +logger = logging.getLogger(__name__) + +def _normalize_candles(candles): + """Convert candles to standard list of dicts.""" + if not candles: + return [] + if isinstance(candles[0], dict) and 'timestamp' in candles[0]: + return candles + normalized = [] + for item in candles: + if len(item) >= 6: + ts, o, h, l, c, v = item[:6] + normalized.append({ + 'timestamp': ts, + 'open': float(o), + 'high': float(h), + 'low': float(l), + 'close': float(c), + 'volume': float(v) if v else 0 + }) + elif len(item) == 5: + ts, o, h, l, c = item + normalized.append({ + 'timestamp': ts, + 'open': float(o), + 'high': float(h), + 'low': float(l), + 'close': float(c), + 'volume': 0 + }) + return normalized + + +def _convert_timestamps_to_datetime(candles): + """Convert numeric timestamps to datetime objects.""" + if not candles: + return candles + for c in candles: + ts = c['timestamp'] + if isinstance(ts, (int, float)): + if ts > 1e12: + ts = ts / 1000.0 + c['timestamp'] = pd.to_datetime(ts, unit='s') + return candles + +def calculate_spread_series(candles1, candles2): + """ + Align two candlestick series and compute: + - spread % + - rolling mean + - z-score + """ + + # ========================================== + # NORMALIZE INPUT + # ========================================== + + df1 = pd.DataFrame(_normalize_candles(candles1)) + df2 = pd.DataFrame(_normalize_candles(candles2)) + + if df1.empty or df2.empty: + return [] + + # ========================================== + # TIMESTAMP HANDLING + # ========================================== + + df1["timestamp"] = pd.to_datetime(df1["timestamp"]) + df2["timestamp"] = pd.to_datetime(df2["timestamp"]) + + df1 = df1.sort_values("timestamp") + df2 = df2.sort_values("timestamp") + + # ========================================== + # ALIGN CANDLES + # ========================================== + + df = pd.merge_asof( + df1, + df2, + on="timestamp", + suffixes=("_1", "_2"), + direction="nearest", + tolerance=pd.Timedelta("2min") + ) + + # Remove rows without valid aligned candles + df = df.dropna(subset=["close_1", "close_2"]) + + if df.empty: + return [] + + # ========================================== + # NUMERIC CONVERSION + # ========================================== + + df["close_1"] = pd.to_numeric(df["close_1"], errors="coerce") + df["close_2"] = pd.to_numeric(df["close_2"], errors="coerce") + + df = df.dropna(subset=["close_1", "close_2"]) + + if df.empty: + return [] + + # ========================================== + # SPREAD CALCULATION + # ========================================== + + # Log spread = more statistically stable + spread_pct = np.log(df["close_2"] / df["close_1"]) * 100 + + # ========================================== + # ROLLING STATISTICS + # ========================================== + + window = 20 + + rolling_mean = spread_pct.rolling(window=window).mean() + rolling_std = spread_pct.rolling(window=window).std() + + # Avoid division by zero + zscores = (spread_pct - rolling_mean) / (rolling_std + 1e-9) + + # ========================================== + # BUILD OUTPUT + # ========================================== + + spreads = [] + + for i in range(len(df)): + + spreads.append({ + "time": df["timestamp"].iloc[i], + + "spread": ( + float(spread_pct.iloc[i]) + if not np.isnan(spread_pct.iloc[i]) + else 0.0 + ), + + "mean": ( + float(rolling_mean.iloc[i]) + if not np.isnan(rolling_mean.iloc[i]) + else 0.0 + ), + + "zscore": ( + float(zscores.iloc[i]) + if not np.isnan(zscores.iloc[i]) + else 0.0 + ), + }) + + return spreads + +def generate_chart( + config: Dict[str, Any], + candles_data: List[Dict[str, Any]], + current_price: Optional[float] = None, + grid_analysis: Optional[Dict[str, Any]] = None, +) -> io.BytesIO: + """Generate chart with 4 panels: Prices, Spread, Z-Score, Cumulative Profit.""" + + # Normalizza e converti timestamp + candles1 = _convert_timestamps_to_datetime(_normalize_candles(candles_data)) + candles2 = _convert_timestamps_to_datetime(_normalize_candles(config.get("candles_exchange_2", []))) + + # Limita a 80 candele + MAX_CANDLES = 240 + if len(candles1) > MAX_CANDLES: + candles1 = candles1[-MAX_CANDLES:] + if len(candles2) > MAX_CANDLES: + candles2 = candles2[-MAX_CANDLES:] + + if not candles1: + buf = io.BytesIO() + fig = go.Figure() + fig.add_annotation(text="No candle data available", x=0.5, y=0.5, showarrow=False) + fig.write_image(buf, format="png") + buf.seek(0) + return buf + + # Extract configuration + ep1 = config.get("exchange_pair_1", {}) + ep2 = config.get("exchange_pair_2", {}) + connector1 = ep1.get("connector_name", "Exchange 1") + connector2 = ep2.get("connector_name", "Exchange 2") + pair1 = ep1.get("trading_pair", "Unknown") + pair2 = ep2.get("trading_pair", "Unknown") + min_profit = float(config.get("min_profitability", 0.005)) + + # Parametri per il calcolo del profitto + capital = float(config.get("total_amount_quote", 1000)) + fee_rate_1 = float(config.get("fee_rate_exchange_1", 0.0005)) + fee_rate_2 = float(config.get("fee_rate_exchange_2", 0.0005)) + slippage = float(config.get("slippage", 0.0005)) + + title = (f"πŸ”¬ BACKTEST: {connector1} {pair1} ↔ {connector2} {pair2} | " + f"min profit: {min_profit*100:.2f}% | capital: ${capital:,.0f} | " + f"fees: {fee_rate_1*100:.2f}%/{fee_rate_2*100:.2f}%") + + # Calcola lo spread + spread_data = calculate_spread_series(candles1, candles2) + if spread_data: + spreads = [s["spread"] for s in spread_data] + logger.info(f"Spread min: {min(spreads):.4f}%, max: {max(spreads):.4f}%") + logger.info(f"Z-Score min: {min([s['zscore'] for s in spread_data]):.2f}, max: {max([s['zscore'] for s in spread_data]):.2f}") + + + + if not spread_data: + buf = io.BytesIO() + fig = go.Figure() + fig.add_annotation(text="No spread data available", x=0.5, y=0.5, showarrow=False) + fig.write_image(buf, format="png") + buf.seek(0) + return buf + + # TROVA IL PRIMO INDICE DOVE ZSCORE È VALIDO + start_idx = 0 + for i, s in enumerate(spread_data): + if not np.isnan(s["zscore"]): + start_idx = i + break + + # Se non trovato o troppo vicino all'inizio, usa un offset minimo di 10 + if start_idx < 10: + start_idx = 10 + + # TAGLIA TUTTI I DATI DALLO STESSO PUNTO DI PARTENZA + spread_data = spread_data[start_idx:] + + # Allinea anche le candele dei prezzi + if len(candles1) > start_idx: + candles1 = candles1[start_idx:] + if len(candles2) > start_idx: + candles2 = candles2[start_idx:] + + # Create 3 subplots + fig = make_subplots( + rows=3, + cols=1, + shared_xaxes=True, + vertical_spacing=0.08, + row_heights=[0.45, 0.30, 0.25], + subplot_titles=( + f"Prices: {connector1} (cyan) vs {connector2} (orange)", + + f"Spread % | Entry Thresholds Β±{min_profit*100:.2f}%", + + f"Cumulative Profit " + f"(capital: ${capital:,.0f} | fees: {fee_rate_1*100:.2f}% + {fee_rate_2*100:.2f}%)" + ) + ) + + # ========================================== + # ROW 1: Price chart + # ========================================== + if candles1: + df1 = pd.DataFrame(candles1).sort_values("timestamp") + fig.add_trace( + go.Scatter( + x=df1["timestamp"], + y=df1["close"], + mode="lines", + name=f"{connector1} {pair1}", + line=dict(color="#00d4ff", width=2.5), + ), + row=1, col=1 + ) + + if candles2: + df2 = pd.DataFrame(candles2).sort_values("timestamp") + fig.add_trace( + go.Scatter( + x=df2["timestamp"], + y=df2["close"], + mode="lines", + name=f"{connector2} {pair2}", + line=dict(color="orange", width=2.5), + ), + row=1, col=1 + ) + + if candles1 and len(candles1) == len(candles2): + df1_aligned = pd.DataFrame(candles1).sort_values("timestamp") + fig.add_trace( + go.Scatter( + x=df1_aligned["timestamp"], + y=df2["close"], + mode='lines', + fill='tonexty', + name="Differenza", + line=dict(width=0), + fillcolor='rgba(255,165,0,0.15)', + showlegend=False + ), + row=1, col=1 + ) + + fig.add_annotation( + xref="x domain", yref="y domain", x=0.02, y=0.98, + xanchor="left", yanchor="top", + text=f"{connector1} ● | {connector2} ●", + showarrow=False, font=dict(size=11, color="white"), + bgcolor='rgba(0,0,0,0.5)', borderpad=6, borderwidth=1, bordercolor='#444', + row=1, col=1 + ) + + # ========================================== + # ROW 2: Spread % + # ========================================== + spread_vals = [s["spread"] for s in spread_data if s["spread"] != 0] + if spread_vals: + min_spread, max_spread = min(spread_vals), max(spread_vals) + margin = (max_spread - min_spread) * 0.15 if max_spread != min_spread else 0.2 + + fig.add_trace( + go.Scatter(x=[s["time"] for s in spread_data], y=[s["spread"] for s in spread_data], + mode="lines", name="Spread %", + line=dict(color="cyan", width=2)), + row=2, col=1 + ) + fig.add_trace( + go.Scatter(x=[s["time"] for s in spread_data], y=[s["mean"] for s in spread_data], + mode="lines", name="Mean (20)", + line=dict(color="orange", width=1.5, dash="dash")), + row=2, col=1 + ) + + fig.update_yaxes(range=[min_spread - margin, max_spread + margin], row=2, col=1) + + fig.add_hline(y=min_profit * 100, line_dash="dot", line_color="green", + row=2, col=1, annotation_text=f"+{min_profit*100:.2f}%") + fig.add_hline(y=-min_profit * 100, line_dash="dot", line_color="red", + row=2, col=1, annotation_text=f"-{min_profit*100:.2f}%") + + fig.add_annotation( + xref="x domain", yref="y domain", x=0.02, y=0.98, + xanchor="left", yanchor="top", + text="Spread % ● | Mean (20) --- | Thresholds + -", + showarrow=False, font=dict(size=11, color="white"), + bgcolor='rgba(0,0,0,0.5)', borderpad=6, borderwidth=1, bordercolor='#444', + row=2, col=1 + ) + + # ========================================== + # ROW 3: REALIZED CUMULATIVE PROFIT + # ========================================== + + realized_profit = 0.0 + position = None + entry_spread = None + + total_fee_pct = ( + fee_rate_1 + + fee_rate_2 + + (slippage * 2) + ) * 100 + + # Calcola soglia dinamica basata sullo spread MASSIMO (non sulla media) + if spread_vals: + max_spread = max(spread_vals) + # Entra quando lo spread supera l'80% del massimo storico + ENTRY_SPREAD = max_spread * 0.8 + # Soglia minima per evitare rumore (0.01%) + MIN_THRESHOLD = 0.01 + ENTRY_SPREAD = max(ENTRY_SPREAD, MIN_THRESHOLD) + logger.info(f"MAX_SPREAD: {max_spread:.4f}%, ENTRY_SPREAD: {ENTRY_SPREAD:.4f}%") + else: + ENTRY_SPREAD = min_profit * 100 + + EXIT_SPREAD = ENTRY_SPREAD * 0.25 + + equity = [] + profit_times = [] + + for s in spread_data: + spread = s["spread"] + + # ====================================== + # ENTRY LOGIC + # ====================================== + if position is None: + if spread < -ENTRY_SPREAD: + position = "long" + entry_spread = spread + logger.info(f"LONG ENTRY at spread: {spread:.4f}%") + elif spread > ENTRY_SPREAD: + position = "short" + entry_spread = spread + logger.info(f"SHORT ENTRY at spread: {spread:.4f}%") + + # ====================================== + # EXIT LONG + # ====================================== + elif position == "long": + if spread >= -EXIT_SPREAD: + spread_move = spread - entry_spread + trade_profit_pct = spread_move - total_fee_pct + #trade_profit_pct = max(trade_profit_pct, 0) + realized_profit += (trade_profit_pct / 100) * capital + logger.info(f"LONG EXIT at spread: {spread:.4f}%, profit: ${realized_profit:.2f}") + position = None + entry_spread = None + + # ====================================== + # EXIT SHORT + # ====================================== + elif position == "short": + if spread <= EXIT_SPREAD: + spread_move = entry_spread - spread + trade_profit_pct = spread_move - total_fee_pct + #trade_profit_pct = max(trade_profit_pct, 0) + realized_profit += (trade_profit_pct / 100) * capital + logger.info(f"SHORT EXIT at spread: {spread:.4f}%, profit: ${realized_profit:.2f}") + position = None + entry_spread = None + + equity.append(realized_profit) + profit_times.append(s["time"]) + # ========================================== + # SAFETY CHECK + # ========================================== + if len(equity) < 2: + equity = [0, 0] + profit_times = [spread_data[0]["time"], spread_data[-1]["time"]] + + final_profit_usd = equity[-1] + + line_color = "#00ff88" if final_profit_usd >= 0 else "#ff4444" + area_color = "rgba(0,255,136,0.15)" if final_profit_usd >= 0 else "rgba(255,68,68,0.15)" + + # ========================================== + # PLOT + # ========================================== + fig.add_trace( + go.Scatter( + x=profit_times, + y=equity, + mode="lines", + name="Equity Curve", + line=dict(color=line_color, width=2), + fill="tozeroy", + fillcolor=area_color + ), + row=3, + col=1 + ) + + fig.add_hline( + y=0, + line_dash="solid", + line_color="gray", + row=3, + col=1, + opacity=0.5 + ) + + fig.update_yaxes( + title_text="Profit (USD)", + row=3, + col=1, + autorange=True + ) + + profit_color = "#00ff88" if final_profit_usd >= 0 else "#ff4444" + + # In ROW 3, modifica l'annotazione del profit + fig.add_annotation( + xref="x domain", yref="y domain", x=0.02, y=0.98, # ← x=0.02 (sinistra) + xanchor="left", yanchor="top", + text=f"Backtest Final Profit: ${final_profit_usd:.2f} | " + f"ROI: {final_profit_usd/capital*100:.2f}%", + showarrow=False, font=dict(size = 11, color="white"), + bgcolor='rgba(15,18,25,0.82)', borderpad=6, borderwidth=1, bordercolor='#444', + row=3, col=1 + ) + + # ========================================== + # LAYOUT + # ========================================== + fig.update_layout( + title=dict(text=title, font=dict(size=13, color="white"), x=0.5), + template="plotly_dark", + height=1050, + hovermode="x unified", + plot_bgcolor='#0e1117', + paper_bgcolor='#0e1117', + showlegend=False + ) + + fig.update_xaxes( + gridcolor='#2a2f3a', + showgrid=True, + gridwidth=0.5, + title_font=dict(color="white", size=10), + tickfont=dict(color="white", size=9), + tickformat="%H:%M\n%d/%m" + ) + fig.update_yaxes( + gridcolor='#2a2f3a', + showgrid=True, + gridwidth=0.5, + title_font=dict(color="white", size=10), + tickfont=dict(color="white", size=9) + ) + + fig.update_yaxes(title_text="Price (USD)", row=1, col=1) + fig.update_yaxes(title_text="Spread (%)", row=2, col=1) + fig.update_yaxes(title_text="Profit (USD)", row=3, col=1) + fig.update_xaxes(title_text="Time", row=3, col=1) + + buf = io.BytesIO() + fig.write_image(buf, format="png", scale=2) + buf.seek(0) + return buf + + +def generate_preview_chart( + config: Dict[str, Any], + candles_data: List[Dict[str, Any]], + current_price: Optional[float] = None +) -> io.BytesIO: + """Preview chart without grid analysis overlays.""" + return generate_chart(config, candles_data, current_price, grid_analysis=None) diff --git a/handlers/bots/controllers/arbitrage_controller/config.py b/handlers/bots/controllers/arbitrage_controller/config.py new file mode 100644 index 00000000..0bc50912 --- /dev/null +++ b/handlers/bots/controllers/arbitrage_controller/config.py @@ -0,0 +1,302 @@ +""" +Arbitrage Controller configuration with dynamic exchange validation and auto-optimization. + +CEX/DEX (or CEX/CEX, DEX/DEX) arbitrage strategy that simultaneously +buys on one exchange and sells on another when profitability exceeds +the minimum threshold. + +Config structure matches ArbitrageControllerConfig in hummingbot-api: +- exchange_pair_1: {connector_name, trading_pair} (nested object) +- exchange_pair_2: {connector_name, trading_pair} (nested object) +- rate_connector: used to fetch conversion rates (gas token, quote conversion) +- quote_conversion_asset: asset used to normalize profits (usually USDT) + +Gas fees for DEX connectors are handled automatically by hummingbot +via GatewayHttpClient β€” no manual gas configuration needed. +""" + +import asyncio +import logging +from typing import Any, Dict, List, Optional, Tuple +from decimal import Decimal + +import numpy as np + +from .._base import ControllerField + +logger = logging.getLogger(__name__) + +# ============================================ +# DEFAULTS with intelligent values +# ============================================ + +DEFAULTS: Dict[str, Any] = { + "controller_name": "arbitrage_controller", + "controller_type": "generic", + "id": "", + "total_amount_quote": 1000, + # exchange_pair_1 and exchange_pair_2 are nested ConnectorPair objects + "exchange_pair_1": { + "connector_name": "binance", + "trading_pair": "SOL-USDT", + }, + "exchange_pair_2": { + "connector_name": "jupiter/router", + "trading_pair": "SOL-USDC", + }, + "min_profitability": 0.005, # 0.5% (more realistic) + "delay_between_executors": 5, # 5 seconds + "max_executors_imbalance": 2, + "rate_connector": "binance", + "quote_conversion_asset": "USDT", + # Base fields from ControllerConfigBase + "manual_kill_switch": None, + "candles_config": [], + "backtest_interval": "5m", # timeframe delle candele (1m, 5m, 15m, 1h, 4h, 1d) + "backtest_candles": 500, # numero di candele da fetchare (max 1000) +} + +# ============================================ +# FIELD DEFINITIONS +# ============================================ + +FIELDS: Dict[str, ControllerField] = { + "id": ControllerField( + name="id", label="Config ID", type="str", required=True, hint="Auto-generated" + ), + "total_amount_quote": ControllerField( + name="total_amount_quote", label="Total Amount (Quote)", type="float", + required=True, hint="Total capital in quote asset (e.g. 1000 USDT)" + ), + "exchange_pair_1_connector": ControllerField( + name="exchange_pair_1_connector", label="Exchange 1 Connector", type="str", + required=True, hint="e.g. binance, kucoin, hyperliquid_perpetual" + ), + "exchange_pair_1_pair": ControllerField( + name="exchange_pair_1_pair", label="Exchange 1 Pair", type="str", + required=True, hint="e.g. SOL-USDT" + ), + "exchange_pair_2_connector": ControllerField( + name="exchange_pair_2_connector", label="Exchange 2 Connector", type="str", + required=True, hint="e.g. jupiter/router, uniswap/ethereum" + ), + "exchange_pair_2_pair": ControllerField( + name="exchange_pair_2_pair", label="Exchange 2 Pair", type="str", + required=True, hint="e.g. SOL-USDC (can differ if quote assets differ)" + ), + "min_profitability": ControllerField( + name="min_profitability", label="Min Profitability", type="float", + required=True, hint="Min profit to execute (e.g. 0.01 = 1%)", default=0.005 + ), + "delay_between_executors": ControllerField( + name="delay_between_executors", label="Delay Between Exec (s)", type="int", + required=False, hint="Seconds between executor creation (default: 5)", default=5 + ), + "max_executors_imbalance": ControllerField( + name="max_executors_imbalance", label="Max Imbalance", type="int", + required=False, hint="Max buy/sell imbalance before pausing (default: 2)", default=2 + ), + "rate_connector": ControllerField( + name="rate_connector", label="Rate Connector", type="str", + required=False, hint="CEX for conversion rates, e.g. binance", default="binance" + ), + "quote_conversion_asset": ControllerField( + name="quote_conversion_asset", label="Quote Conversion Asset", type="str", + required=False, hint="Asset to normalize profits, e.g. USDT", default="USDT" + ), + "manual_kill_switch": ControllerField( + name="manual_kill_switch", label="Kill Switch", type="bool", + required=False, hint="Manual kill switch", default=None + ), + "backtest_interval": ControllerField( + name="backtest_interval", label="Backtest Interval", type="str", + required=False, hint="Candle timeframe (1m, 5m, 15m, 1h, 4h, 1d)", default="5m" + ), + "backtest_candles": ControllerField( + name="backtest_candles", label="Backtest Candles", type="int", + required=False, hint="Number of candles for backtest (100-1000)", default=500 + ), + "fee_rate_exchange_1": ControllerField( + name="fee_rate_exchange_1", + label="Fee Rate Exchange 1", + type="float", + required=False, + hint="Trading fee exchange 1 (e.g. 0.001 = 0.1%)", + default=0.001 + ), + + "fee_rate_exchange_2": ControllerField( + name="fee_rate_exchange_2", + label="Fee Rate Exchange 2", + type="float", + required=False, + hint="Trading fee exchange 2 (e.g. 0.001 = 0.1%)", + default=0.001 + ), +} + +FIELD_ORDER: List[str] = [ + "id", + "delay_between_executors", + "exchange_pair_1_connector","exchange_pair_1_pair", + "exchange_pair_2_connector", "exchange_pair_2_pair", + "fee_rate_exchange_1", + "fee_rate_exchange_2", + "max_executors_imbalance", + "min_profitability", + "quote_conversion_asset", + "rate_connector", + "total_amount_quote" +] + +EDITABLE_FIELDS: List[str] = [ + "delay_between_executors", + "exchange_pair_1_connector", "exchange_pair_1_pair", + "exchange_pair_2_connector", "exchange_pair_2_pair", + "fee_rate_exchange_1", + "fee_rate_exchange_2", + "max_executors_imbalance", + "min_profitability", + "quote_conversion_asset", + "rate_connector", + "total_amount_quote" + +] + +# ============================================ +# HELPER FUNCTIONS +# ============================================ + +def get_flat_fields(config: Dict[str, Any]) -> Dict[str, Any]: + """Extract flat key=value fields for the edit form.""" + ep1 = config.get("exchange_pair_1", {}) or {} + ep2 = config.get("exchange_pair_2", {}) or {} + + + return { + "total_amount_quote": config.get("total_amount_quote", 1000), + "exchange_pair_1_connector": ep1.get("connector_name", ""), + "exchange_pair_1_pair": ep1.get("trading_pair", ""), + "exchange_pair_2_connector": ep2.get("connector_name", ""), + "exchange_pair_2_pair": ep2.get("trading_pair", ""), + "min_profitability": config.get("min_profitability", 0.005), + "delay_between_executors": config.get("delay_between_executors", 5), + "max_executors_imbalance": config.get("max_executors_imbalance", 2), + "rate_connector": config.get("rate_connector", "binance"), + "quote_conversion_asset": config.get("quote_conversion_asset", "USDT"), + "fee_rate_exchange_1": config.get("fee_rate_exchange_1", 0.001), + "fee_rate_exchange_2": config.get("fee_rate_exchange_2", 0.001), + } + + +def apply_flat_fields(config: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]: + """Apply flat key=value updates back into the nested config structure.""" + for key, value in updates.items(): + if key == "exchange_pair_1_connector": + if "exchange_pair_1" not in config: + config["exchange_pair_1"] = {} + config["exchange_pair_1"]["connector_name"] = value + elif key == "exchange_pair_1_pair": + if "exchange_pair_1" not in config: + config["exchange_pair_1"] = {} + config["exchange_pair_1"]["trading_pair"] = value + elif key == "exchange_pair_2_connector": + if "exchange_pair_2" not in config: + config["exchange_pair_2"] = {} + config["exchange_pair_2"]["connector_name"] = value + elif key == "exchange_pair_2_pair": + if "exchange_pair_2" not in config: + config["exchange_pair_2"] = {} + config["exchange_pair_2"]["trading_pair"] = value + else: + config[key] = value + return config + + +def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + """Validate configuration with enhanced business logic.""" + ep1 = config.get("exchange_pair_1", {}) or {} + ep2 = config.get("exchange_pair_2", {}) or {} + + # Basic validation + if not ep1.get("connector_name"): + return False, "Missing exchange_pair_1 connector_name" + if not ep1.get("trading_pair"): + return False, "Missing exchange_pair_1 trading_pair" + if not ep2.get("connector_name"): + return False, "Missing exchange_pair_2 connector_name" + if not ep2.get("trading_pair"): + return False, "Missing exchange_pair_2 trading_pair" + + total_amount = float(config.get("total_amount_quote", 0)) + if total_amount <= 0: + return False, "total_amount_quote must be positive" + + # Validate base assets match + pair1 = ep1.get("trading_pair", "") + pair2 = ep2.get("trading_pair", "") + + if "-" in pair1 and "-" in pair2: + base1 = pair1.split("-")[0] + base2 = pair2.split("-")[0] + + if base1 != base2: + return False, f"Base assets must match: {base1} vs {base2}" + + # Validate profitability + min_prof = float(config.get("min_profitability", 0)) + if min_prof <= 0: + return False, "min_profitability must be positive" + if min_prof > 0.5: + return False, "min_profitability too high (>50%)" + + # Validate delay + delay = config.get("delay_between_executors", 5) + if delay < 1: + return False, "delay_between_executors must be at least 1 second" + return True, None + + +def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + """Generate unique ID for the configuration.""" + max_num = 0 + for cfg in existing_configs: + parts = cfg.get("id", "").split("_", 1) + if parts and parts[0].isdigit(): + max_num = max(max_num, int(parts[0])) + seq = str(max_num + 1).zfill(3) + + ep1 = config.get("exchange_pair_1", {}) or {} + ep2 = config.get("exchange_pair_2", {}) or {} + c1 = ep1.get("connector_name", "ex1").replace("_perpetual", "").replace("_spot", "").replace("/", "-") + c2 = ep2.get("connector_name", "ex2").replace("_perpetual", "").replace("_spot", "").replace("/", "-") + pair = ep1.get("trading_pair", "UNKNOWN").replace("-", "_") + return f"{seq}_arb_{c1}_{c2}_{pair}" + + +# ============================================ +# EXCHANGE DATA FETCHER (internal) +# ============================================ + + +async def _get_current_price(connector_name: str, trading_pair: str, use_mid: bool = True) -> Optional[float]: + """Get current price from connector.""" + cache_key = f"price_{connector_name}_{trading_pair}" + cached = _cache.get(cache_key) + if cached is not None: + return cached + + try: + from hummingbot.client.hummingbot_application import HummingbotApplication + app = HummingbotApplication.main_application() + + if connector_name in app.connectors: + connector = app.connectors[connector_name] + price = connector.get_price(trading_pair, False) + if price: + _cache.set(cache_key, float(price)) + return float(price) + except Exception as e: + logger.debug(f"Failed to get price: {e}") + + return None diff --git a/handlers/bots/controllers/bollingrid/__init__.py b/handlers/bots/controllers/bollingrid/__init__.py new file mode 100644 index 00000000..7172187c --- /dev/null +++ b/handlers/bots/controllers/bollingrid/__init__.py @@ -0,0 +1,46 @@ +""" +Bollinger Grid Controller Module + +Grid trading with Bollinger Bands signal. +""" + +import io +from typing import Any, Dict, List, Optional, Tuple + +from .._base import BaseController, ControllerField +from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config +from .chart import generate_chart # <-- usa il chart specifico + + +class BollinGridController(BaseController): + controller_type = "bollingrid" + display_name = "Bollinger Grid" + description = "Grid trading with Bollinger Bands signals" + + @classmethod + def get_defaults(cls) -> Dict[str, Any]: + return DEFAULTS.copy() + + @classmethod + def get_fields(cls) -> Dict[str, ControllerField]: + return FIELDS + + @classmethod + def get_field_order(cls) -> List[str]: + return FIELD_ORDER + + @classmethod + def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + 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: + # Usa il chart specifico per Bollinger Grid + return generate_chart(config, candles_data, current_price) + + @classmethod + def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + return generate_id(config, existing_configs) + + +__all__ = ["BollinGridController"] diff --git a/handlers/bots/controllers/bollingrid/chart.py b/handlers/bots/controllers/bollingrid/chart.py new file mode 100644 index 00000000..a5c0695b --- /dev/null +++ b/handlers/bots/controllers/bollingrid/chart.py @@ -0,0 +1,408 @@ +""" +Bollinger Grid chart generation. + +3 panels: + 1. Price – candlesticks + BB + grid lines (start, end, limit) + 2. Volume – colored bars + 3. BBP – Bollinger Band Percent with long/short thresholds +""" + +import io +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import logging +import matplotlib.dates as mdates +from matplotlib.patches import Rectangle +from datetime import datetime + +def generate_chart(config, candles_data, current_price=None): + if not candles_data or len(candles_data) < 5: + return _generate_simple_chart(candles_data, current_price) + + df = _prepare_dataframe(candles_data) + if df is None or df.empty: + return _generate_simple_chart(candles_data, current_price) + + # πŸ”₯ SAFETY PATCH (QUI) + for col in ['open','high','low','close','volume']: + if col not in df.columns: + df[col] = 0 + + full_df = df.copy() + + MAX_VISIBLE_CANDLES = 96 + + for col in ['open', 'high', 'low', 'close', 'volume']: + full_df[col] = pd.to_numeric(full_df.get(col, 0), errors='coerce').fillna(0) + + logger = logging.getLogger(__name__) + + # ── INDICATORI ────────────────────────────────────────────────── + bb_length = int(config.get('bb_length', 100)) + bb_std_val = float(config.get('bb_std', 2.0)) + + # Se non ci sono abbastanza dati, riduci bb_length + if len(df) < bb_length: + bb_length = max(20, len(df) // 2) + logger.info(f"BG Chart: reduced bb_length to {bb_length} due to insufficient data") + + # Calcola BB con min_periods=1 per avere valori dall'inizio + rolling = full_df['close'].rolling(window=bb_length, min_periods=1) + full_df['bb_mid'] = rolling.mean() + bb_std_series = rolling.std(ddof=0) + bb_std_series = bb_std_series.replace(0, 0.0001) # evita std=0 + + full_df['bb_upper'] = full_df['bb_mid'] + bb_std_val * bb_std_series + full_df['bb_lower'] = full_df['bb_mid'] - bb_std_val * bb_std_series + + # ── CALCOLO BBP ──────────────────────────────────────────────── + denom = full_df['bb_upper'] - full_df['bb_lower'] + denom = denom.replace(0, np.nan) + bbp_values = (full_df['close'] - full_df['bb_lower']) / denom + bbp_values = bbp_values.fillna(0.5) + bbp_values = bbp_values.clip(-1, 2) + full_df['bbp'] = bbp_values + df = full_df.tail(MAX_VISIBLE_CANDLES).copy() + + # ── PREZZI DELLA GRIGLIA ──────────────────────────────────────── + start_price = config.get('start_price', 0) + end_price = config.get('end_price', 0) + limit_price = config.get('limit_price', 0) + if df.empty: + current_price = 0 + elif current_price is None: + current_price = float(df['close'].iloc[-1]) + + # ── FIGURA ────────────────────────────────────────────────────── + + fig, (ax1, ax2, ax3) = plt.subplots( + 3, + 1, + figsize=(22, 12), + sharex=True, + gridspec_kw={ + 'height_ratios': [4.5, 1.2, 1.5] + } + ) + + fig.patch.set_facecolor('#111111') + + for ax in [ax1, ax2, ax3]: + ax.set_facecolor('#111111') + ax.tick_params(colors='white') + ax.yaxis.label.set_color('white') + ax.spines['bottom'].set_color('#444') + ax.spines['top'].set_color('#444') + ax.spines['left'].set_color('#444') + ax.spines['right'].set_color('#444') + + dates = mdates.date2num(df['datetime']) + + if len(dates) > 1: + + candle_width = (dates[1] - dates[0]) * 0.85 + volume_width = (dates[1] - dates[0]) * 0.85 + + else: + + candle_width = volume_width = 0.0005 + + # ── PANNELLO 1: PREZZO + BOLLINGER BANDS + GRIGLIA ────────────── + + # Candele + for i in range(len(df)): + o, h, l, c = df.iloc[i][['open', 'high', 'low', 'close']] + color = '#2ecc71' if c >= o else '#e74c3c' + ax1.plot([dates[i], dates[i]], [l, h], color=color, linewidth=1) + ax1.add_patch(Rectangle( + (dates[i] - candle_width / 2, min(o, c)), + candle_width, abs(c - o) or 1e-8, + color=color + )) + + # Bollinger Bands (colori diversi) + ax1.plot(df['datetime'], df['bb_upper'], '--', linewidth=1, color='#1f77b4', alpha=0.8, label='BB Upper') + ax1.plot(df['datetime'], df['bb_mid'], ':', linewidth=1, color='#ff7f0e', alpha=0.8, label='BB Mid') + ax1.plot(df['datetime'], df['bb_lower'], '--', linewidth=1, color='#2ca02c', alpha=0.8, label='BB Lower') + + # Linee della griglia (sempre mostrate) + if start_price > 0: + ax1.axhline(y=start_price, linestyle='-', linewidth=1.5, color='green', alpha=0.9, label=f'Start ({start_price:.4f})') + if end_price > 0: + ax1.axhline(y=end_price, linestyle='-', linewidth=1.5, color='red', alpha=0.9, label=f'End ({end_price:.4f})') + if limit_price > 0: + ax1.axhline(y=limit_price, linestyle='--', linewidth=1.5, color='orange', alpha=0.9, label=f'Limit ({limit_price:.4f})') + + # Prezzo corrente + if current_price: + ax1.axhline(y=current_price, linestyle='--', linewidth=1, color='purple', alpha=0.6, label=f'Current ({current_price:.4f})') + + # ── GRID ZONE DINAMICA ───────────────────────────────────────────── + + visible_candles = len(df) + + # intensitΓ  fill adattiva + if visible_candles <= 32: + grid_alpha = 0.06 + elif visible_candles <= 64: + grid_alpha = 0.08 + elif visible_candles <= 96: + grid_alpha = 0.11 + else: + grid_alpha = 0.14 + + # colore meno "sporco" + grid_color = '#d4c900' + + if start_price > 0 and end_price > 0 and start_price < end_price: + + ax1.axhspan(start_price, end_price, alpha=grid_alpha, color=grid_color, label='Grid Zone', zorder=0) + + # bordi zona piΓΉ leggibili + ax1.axhline(start_price, color='#00ff88', linewidth=1.2, alpha=0.75) + + ax1.axhline(end_price, color='#ff4d6d', linewidth=1.2, alpha=0.75) + + # Calcola i limiti Y includendo le linee della griglia + price_min = df['low'].min() + price_max = df['high'].max() + + # Includi start_price e limit_price se sono nel range + if start_price > 0: + price_min = min(price_min, start_price) + if limit_price > 0: + price_min = min(price_min, limit_price) + if end_price > 0: + price_max = max(price_max, end_price) + + # Aggiungi un margine del 5% + margin = (price_max - price_min) * 0.05 + y_min = price_min - margin + y_max = price_max + margin + + ax1.set_ylim(y_min, y_max) + + ax1.legend(loc='upper left', fontsize=9, ncol=2, framealpha=0) + ax1.set_ylabel('Price') + ax1.grid(True, alpha=0.3) + ax1.set_xlim(df['datetime'].min(), df['datetime'].max()) + + # ── PANNELLO 2: VOLUME ────────────────────────────────────────── + if 'volume' in df.columns and df['volume'].sum() > 0: + vol_colors = [ + '#2ecc71' if df['close'].iloc[i] >= df['open'].iloc[i] else '#e74c3c' + for i in range(len(df)) + ] + ax2.bar(dates, df['volume'], width=volume_width, color=vol_colors, alpha=0.7) + ax2.set_ylabel('Volume') + ax2.grid(True, alpha=0.3) + + # ── PANNELLO 3: BBP ───────────────────────────────────────────── + + + long_thr = float(config.get('bb_long_threshold', 0.0)) + short_thr = float(config.get('bb_short_threshold', 1.0)) + ax3.plot(df['datetime'], df['bbp'], linewidth=1.5, color='#3da5ff') + ax3.axhline(long_thr, linestyle='--', color='green', alpha=0.8, label=f'Long ({long_thr})') + ax3.axhline(short_thr, linestyle='--', color='red', alpha=0.8, label=f'Short ({short_thr})') + ax3.axhline(0, linestyle=':', color='gray', alpha=0.5) + ax3.axhline(1, linestyle=':', color='gray', alpha=0.5) + +# ── BBP SIGNAL ZONES ────────────────────────────────────────────── + + ax3.fill_between(df['datetime'], -1, long_thr, alpha=0.16, color='#00ff88') + ax3.fill_between(df['datetime'], short_thr, 2, alpha=0.16, color='#ff4d6d') + + # Marca i punti di segnale + long_signals = df[df['bbp'] < long_thr] + short_signals = df[df['bbp'] > short_thr] + + if not long_signals.empty: + ax3.scatter(long_signals['datetime'], long_signals['bbp'], + color='green', marker='^', s=30, alpha=0.8, label='Long signal') + if not short_signals.empty: + ax3.scatter(short_signals['datetime'], short_signals['bbp'], + color='red', marker='v', s=30, alpha=0.8, label='Short signal') + + ax3.legend(loc='upper left', fontsize=9, framealpha=0) + ax3.set_ylabel('BBP') + ax3.grid(True, alpha=0.3) + + # ── CONFIGURAZIONE ASSE X (come Grid Strike) ──────────────────── + interval = config.get('interval', '5m') + _setup_x_axis(ax3, df, interval) + + # Imposta i limiti X per tutti i pannelli + x_min = df['datetime'].min() + x_max = df['datetime'].max() + + # Copia locator e formatter agli altri pannelli + ax1.tick_params(labelbottom=False) + ax2.tick_params(labelbottom=False) + for ax in [ax1, ax2]: + ax.set_xlim(x_min, x_max) + ax.xaxis.set_minor_locator(ax3.xaxis.get_minor_locator()) + ax.xaxis.set_major_locator(ax3.xaxis.get_major_locator()) + ax.xaxis.set_major_formatter(ax3.xaxis.get_major_formatter()) + plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center') + + + # ── TITOLO ────────────────────────────────────────────────────── + fig.suptitle( + f"{config.get('trading_pair', 'Unknown')} - Bollinger Grid " + f"(BB{bb_length} | Grid: {start_price:.4f} β†’ {end_price:.4f} | {interval})", + fontsize=13 + ) + + plt.tight_layout() + + buf = io.BytesIO() + plt.savefig(buf, format='png', dpi=120, bbox_inches='tight') + plt.close(fig) + buf.seek(0) + return buf + +def _setup_x_axis(ax, df, interval): + import matplotlib.dates as mdates + + # ── TIMEFRAME CONFIG ────────────────────────────────────────── + + if interval == '1m': + locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45]) + formatter = mdates.DateFormatter('%H:%M') + minor_locator = mdates.MinuteLocator(interval=5) + + elif interval == '5m': + locator = mdates.HourLocator(interval=1) + formatter = mdates.DateFormatter('%H:%M') + minor_locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45]) + + elif interval == '15m': + locator = mdates.HourLocator(interval=2) + formatter = mdates.DateFormatter('%d %H:%M') + minor_locator = mdates.HourLocator(interval=1) + + elif interval == '1h': + locator = mdates.HourLocator(interval=4) + formatter = mdates.DateFormatter('%d %H:%M') + minor_locator = mdates.HourLocator(interval=1) + + elif interval == '4h': + locator = mdates.HourLocator(interval=12) + formatter = mdates.DateFormatter('%d %H:%M') + minor_locator = mdates.HourLocator(interval=4) + + elif interval == '8h': + locator = mdates.DayLocator(interval=2) + formatter = mdates.DateFormatter('%d %b') + minor_locator = mdates.HourLocator(interval=8) + + else: + locator = mdates.AutoDateLocator(minticks=6, maxticks=12) + formatter = mdates.ConciseDateFormatter(locator) + minor_locator = None + + # ── APPLY ───────────────────────────────────────────────────── + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(formatter) + + if minor_locator: + ax.xaxis.set_minor_locator(minor_locator) + + plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center', fontsize=9) + # major vertical grid + ax.grid(True, which='major', axis='x', linestyle='--', alpha=0.15) + ax.set_xlim(df['datetime'].min(), df['datetime'].max() ) + +def _prepare_dataframe(candles, timezone=None): + if timezone is None: + timezone = datetime.now().astimezone().tzinfo + if not candles: + return pd.DataFrame({ + "datetime": pd.date_range(end=pd.Timestamp.now(), periods=1, freq="5min"), + "open": [0], "high": [0], "low": [0], "close": [0], "volume": [0] + }) + df = pd.DataFrame(candles) + + ts_col = next( + (c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), + None + ) + + if ts_col: + sample = df[ts_col].iloc[0] + + if isinstance(sample, (int, float)): + + if sample > 10**12: + # nanoseconds + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='ns', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + + elif sample > 10**10: + # milliseconds + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='ms', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + + else: + # seconds + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='s', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + + else: + + df['datetime'] = ( + pd.to_datetime(df[ts_col], utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + + else: + + df['datetime'] = pd.date_range( + end=pd.Timestamp.now(), + periods=len(df), + freq='5min' + ) + + return df.sort_values('datetime').reset_index(drop=True) + +def _generate_simple_chart(candles_data, current_price): + if not candles_data: + return io.BytesIO() + full_df = _prepare_dataframe(candles_data) + MAX_VISIBLE_CANDLES = 96 + if len(full_df) > MAX_VISIBLE_CANDLES: + df = full_df.tail(MAX_VISIBLE_CANDLES).reset_index(drop=True) + else: + df = full_df.copy() + fig, ax = plt.subplots(figsize=(12, 6)) + if 'close' in df.columns: + ax.plot(df['datetime'], pd.to_numeric(df['close'], errors='coerce'), linewidth=1.5, color='steelblue') + if current_price: + ax.axhline(y=current_price, linestyle='--', color='purple', alpha=0.7) + + ax.set_title('Bollinger Grid - Price Chart') + ax.set_ylabel('Price') + ax.grid(True, alpha=0.3) + _setup_x_axis(ax, df, '5m') + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format='png', dpi=100) + plt.close(fig) + buf.seek(0) + return buf + + +def generate_preview_chart(config, candles_data, current_price=None): + return generate_chart(config, candles_data, current_price) diff --git a/handlers/bots/controllers/bollingrid/config.py b/handlers/bots/controllers/bollingrid/config.py new file mode 100644 index 00000000..8f2324e1 --- /dev/null +++ b/handlers/bots/controllers/bollingrid/config.py @@ -0,0 +1,101 @@ +""" +Bollinger Grid controller configuration. +""" + +from typing import Any, Dict, List, Optional, Tuple + +from .._base import ControllerField + +DEFAULTS: Dict[str, Any] = { + "controller_name": "bollingrid", + "controller_type": "directional_trading", + "id": "", + "connector_name": "", + "trading_pair": "", + "leverage": 1, + "position_mode": "HEDGE", + "total_amount_quote": 1000, + "candles_connector": None, + "candles_trading_pair": None, + "interval": "5m", + "bb_length": 100, + "bb_std": 2.0, + "bb_long_threshold": 0.0, + "bb_short_threshold": 1.0, + "grid_start_price_coefficient": 0.25, + "grid_end_price_coefficient": 0.75, + "grid_limit_price_coefficient": 0.35, + "min_spread_between_orders": 0.005, + "order_frequency": 2, + "max_orders_per_batch": 1, + "min_order_amount_quote": 6, + "max_open_orders": 5, + "stop_loss": 0.05, + "take_profit": 0.03, + "trailing_stop_activation": 0.015, + "trailing_stop_delta": 0.005, +} + +FIELDS: Dict[str, ControllerField] = { + "id": ControllerField(name="id", label="Config ID", type="str", required=True), + "connector_name": ControllerField(name="connector_name", label="Exchange", type="str", required=True), + "trading_pair": ControllerField(name="trading_pair", label="Trading Pair", type="str", required=True), + "leverage": ControllerField(name="leverage", label="Leverage", type="int", required=False, default=1), + "position_mode": ControllerField(name="position_mode", label="Position Mode", type="str", required=False, default="HEDGE"), + "total_amount_quote": ControllerField(name="total_amount_quote", label="Total Amount (USDT)", type="float", required=True), + "candles_connector": ControllerField(name="candles_connector", label="Candles Connector", type="str", required=False), + "candles_trading_pair": ControllerField(name="candles_trading_pair", label="Candles Pair", type="str", required=False), + "interval": ControllerField(name="interval", label="Interval", type="str", required=False, default="5m"), + "bb_length": ControllerField(name="bb_length", label="BB Length", type="int", required=False, default=100), + "bb_std": ControllerField(name="bb_std", label="BB Std Dev", type="float", required=False, default=2.0), + "bb_long_threshold": ControllerField(name="bb_long_threshold", label="BB Long Threshold", type="float", required=False, default=0.0), + "bb_short_threshold": ControllerField(name="bb_short_threshold", label="BB Short Threshold", type="float", required=False, default=1.0), + "grid_start_price_coefficient": ControllerField(name="grid_start_price_coefficient", label="Start Price Coeff", type="float", required=False, default=0.25), + "grid_end_price_coefficient": ControllerField(name="grid_end_price_coefficient", label="End Price Coeff", type="float", required=False, default=0.75), + "grid_limit_price_coefficient": ControllerField(name="grid_limit_price_coefficient", label="Limit Price Coeff", type="float", required=False, default=0.35), + "min_spread_between_orders": ControllerField(name="min_spread_between_orders", label="Min Spread", type="float", required=False, default=0.005), + "order_frequency": ControllerField(name="order_frequency", label="Order Frequency (s)", type="int", required=False, default=2), + "max_orders_per_batch": ControllerField(name="max_orders_per_batch", label="Max Orders/Batch", type="int", required=False, default=1), + "min_order_amount_quote": ControllerField(name="min_order_amount_quote", label="Min Order Amount", type="float", required=False, default=6), + "max_open_orders": ControllerField(name="max_open_orders", label="Max Open Orders", type="int", required=False, default=5), + "stop_loss": ControllerField(name="stop_loss", label="Stop Loss", type="float", required=False, default=0.05), + "take_profit": ControllerField(name="take_profit", label="Take Profit", type="float", required=False, default=0.03), + "trailing_stop_activation": ControllerField(name="trailing_stop_activation", label="TS Activation", type="float", required=False, default=0.015), + "trailing_stop_delta": ControllerField(name="trailing_stop_delta", label="TS Delta", type="float", required=False, default=0.005), +} + +FIELD_ORDER: List[str] = [ + "id", "connector_name", "trading_pair", "leverage", "position_mode", + "total_amount_quote", "candles_connector", "candles_trading_pair", "interval", + "bb_length", "bb_std", "bb_long_threshold", "bb_short_threshold", + "grid_start_price_coefficient", "grid_end_price_coefficient", "grid_limit_price_coefficient", + "min_spread_between_orders", "order_frequency", "max_orders_per_batch", + "min_order_amount_quote", "max_open_orders", "stop_loss", "take_profit", + "trailing_stop_activation", "trailing_stop_delta", +] + +EDITABLE_FIELDS: List[str] = FIELD_ORDER.copy() + + +def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + if not config.get("connector_name"): + return False, "Missing exchange" + if not config.get("trading_pair"): + return False, "Missing trading pair" + if config.get("total_amount_quote", 0) <= 0: + return False, "Total amount must be positive" + return True, None + + +def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + max_num = 0 + for cfg in existing_configs: + cfg_id = cfg.get("id", "") + if cfg_id and cfg_id[:3].isdigit(): + max_num = max(max_num, int(cfg_id[:3])) + seq = str(max_num + 1).zfill(3) + connector = config.get("connector_name", "unknown").replace("_perpetual", "").replace("_spot", "") + pair = config.get("trading_pair", "UNKNOWN").upper() + return f"{seq}_bg_{connector}_{pair}" + + diff --git a/handlers/bots/controllers/delta_neutral_mm/__init__.py b/handlers/bots/controllers/delta_neutral_mm/__init__.py new file mode 100644 index 00000000..cb8ae0d4 --- /dev/null +++ b/handlers/bots/controllers/delta_neutral_mm/__init__.py @@ -0,0 +1,46 @@ +""" +Delta Neutral Market Making Controller Module + +Market making with delta hedging on perpetual exchange. +""" + +import io +from typing import Any, Dict, List, Optional, Tuple + +from .._base import BaseController, ControllerField +from .chart import generate_chart, generate_preview_chart +from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config + + +class DeltaNeutralMMController(BaseController): + controller_type = "delta_neutral_mm" + display_name = "Delta Neutral Market Making" + description = "Market making with delta hedging on perpetual exchange" + + @classmethod + def get_defaults(cls) -> Dict[str, Any]: + return DEFAULTS.copy() + + @classmethod + def get_fields(cls) -> Dict[str, ControllerField]: + return FIELDS + + @classmethod + def get_field_order(cls) -> List[str]: + return FIELD_ORDER + + @classmethod + def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + 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: + return generate_chart(config, candles_data, current_price) + + @classmethod + def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + return generate_id(config, existing_configs) + + +__all__ = ["DeltaNeutralMMController", "DEFAULTS", "FIELDS", "FIELD_ORDER", "EDITABLE_FIELDS", + "validate_config", "generate_id", "generate_chart", "generate_preview_chart"] \ No newline at end of file diff --git a/handlers/bots/controllers/delta_neutral_mm/chart.py b/handlers/bots/controllers/delta_neutral_mm/chart.py new file mode 100644 index 00000000..a3e786a1 --- /dev/null +++ b/handlers/bots/controllers/delta_neutral_mm/chart.py @@ -0,0 +1,245 @@ +""" +Delta Neutral Market Making chart generation. + +4-panel chart: + 1. Price + Reference price (MACD-skewed) + NATR bands + 2. Spreads (buy/sell levels in NATR multiples) + 3. Net delta + hedge thresholds + 4. Combined PnL +""" + +import io +from typing import Any, Dict, List, Optional + +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +import numpy as np +import pandas as pd + + +def generate_chart( + config: Dict[str, Any], + candles_data: List[Dict[str, Any]], + current_price: Optional[float] = None, +) -> io.BytesIO: + """ + Generate a 4-panel chart for Delta Neutral MM. + """ + if not candles_data or len(candles_data) < 10: + return _generate_simple_chart(config, candles_data, current_price) + + # Prepare dataframe + df = _prepare_dataframe(candles_data) + + # Convert numeric columns + for col in ['open', 'high', 'low', 'close', 'volume']: + if col in df.columns: + df[col] = pd.to_numeric(df[col], errors='coerce') + + # Get config values + interval = config.get('interval', '3m') + trading_pair = config.get('connector_pair_maker_trading_pair', 'Unknown') + maker_connector = config.get('connector_pair_maker_connector_name', 'Maker') + hedge_connector = config.get('connector_pair_hedge_connector_name', 'Hedge') + + # Simulate reference price (MACD-skewed) and spread multiplier (NATR) + # In real implementation, these would come from the bot's processed_data + natr = df['close'].pct_change().rolling(14).std().fillna(0.01) + spread_mult = natr * 100 + + # Calculate reference price (close + small random shift for demo) + np.random.seed(42) + price_shift = np.random.normal(0, 0.002, len(df)) + reference_price = df['close'] * (1 + price_shift) + + # Calculate net delta (simulated from price movement) + price_change = df['close'].pct_change().fillna(0) + net_delta = (price_change.cumsum() * 100).fillna(0) + + # Combined PnL + pnl = net_delta * 0.01 # Simulated PnL + + # Hedge thresholds + hedge_threshold = config.get('hedge_threshold_quote', 10) + max_delta = config.get('max_delta_quote', 50) + + # Spread levels + buy_spreads = config.get('buy_spreads', [1.0, 2.0, 3.0]) + sell_spreads = config.get('sell_spreads', [1.0, 2.0, 3.0]) + if isinstance(buy_spreads, str): + buy_spreads = [float(x.strip()) for x in buy_spreads.split(",")] + if isinstance(sell_spreads, str): + sell_spreads = [float(x.strip()) for x in sell_spreads.split(",")] + + # Create figure with 4 subplots + fig, axes = plt.subplots(4, 1, figsize=(14, 12), sharex=True) + ax1, ax2, ax3, ax4 = axes + + # --- Panel 1: Price + Reference Price + NATR bands --- + ax1.plot(df['datetime'], df['close'], linewidth=1.5, color='white', label='Close') + ax1.plot(df['datetime'], reference_price, linewidth=1.5, color='orange', alpha=0.8, label='Reference (MACD-skewed)') + + # NATR bands (Β± spread_multiplier) + upper_band = reference_price * (1 + spread_mult) + lower_band = reference_price * (1 - spread_mult) + ax1.fill_between(df['datetime'], lower_band, upper_band, alpha=0.2, color='blue', label='NATR Bands') + + if current_price: + ax1.axhline(y=current_price, linestyle='--', color='yellow', alpha=0.7, linewidth=1, label=f'Current: {current_price:.4f}') + + ax1.set_ylabel('Price') + ax1.legend(loc='upper left', fontsize=8) + ax1.grid(True, alpha=0.3) + ax1.set_title(f'{trading_pair} - {maker_connector} (Maker) ↔ {hedge_connector} (Hedge)') + + # --- Panel 2: Spreads (buy/sell levels) --- + # Calculate spread values at each point + current_spread = spread_mult.iloc[-1] if not spread_mult.empty else 0.01 + + # Create spread level lines + for i, spread in enumerate(buy_spreads): + ax2.axhline(y=-spread * 100, linestyle='--', color='green', alpha=0.5, linewidth=0.8) + for i, spread in enumerate(sell_spreads): + ax2.axhline(y=spread * 100, linestyle='--', color='red', alpha=0.5, linewidth=0.8) + + # Plot actual spread multiplier over time + ax2.plot(df['datetime'], spread_mult * 100, linewidth=1.5, color='blue', label='NATR Γ— 100%') + + # Add text annotations for levels + y_min, y_max = ax2.get_ylim() + for i, spread in enumerate(buy_spreads): + ax2.text(df['datetime'].iloc[-1], -spread * 100, f'Buy L{i+1} ({spread}Γ—NATR)', + verticalalignment='center', fontsize=7, color='green') + for i, spread in enumerate(sell_spreads): + ax2.text(df['datetime'].iloc[-1], spread * 100, f'Sell L{i+1} ({spread}Γ—NATR)', + verticalalignment='center', fontsize=7, color='red') + + ax2.set_ylabel('Spread (% of price)') + ax2.legend(loc='upper left', fontsize=8) + ax2.grid(True, alpha=0.3) + ax2.set_title('Order Spread Levels (NATR multiples)') + + # --- Panel 3: Net Delta + Hedge Thresholds --- + ax3.plot(df['datetime'], net_delta, linewidth=1.5, color='cyan', label='Net Delta (USDT)') + + # Hedge thresholds + ax3.axhline(y=hedge_threshold, linestyle='--', color='orange', alpha=0.7, label=f'Hedge Threshold (Β±{hedge_threshold})') + ax3.axhline(y=-hedge_threshold, linestyle='--', color='orange', alpha=0.7) + ax3.axhline(y=max_delta, linestyle='--', color='red', alpha=0.7, label=f'Max Delta (Β±{max_delta})') + ax3.axhline(y=-max_delta, linestyle='--', color='red', alpha=0.7) + ax3.axhline(y=0, linestyle='-', color='gray', alpha=0.5) + + # Fill areas beyond thresholds + ax3.fill_between(df['datetime'], net_delta, hedge_threshold, where=(net_delta > hedge_threshold), color='orange', alpha=0.3) + ax3.fill_between(df['datetime'], net_delta, -hedge_threshold, where=(net_delta < -hedge_threshold), color='orange', alpha=0.3) + + ax3.set_ylabel('Net Delta (USDT)') + ax3.legend(loc='upper left', fontsize=8) + ax3.grid(True, alpha=0.3) + ax3.set_title('Net Unhedged Delta') + + # --- Panel 4: Combined PnL --- + # Color based on positive/negative + colors = ['green' if x >= 0 else 'red' for x in pnl] + ax4.bar(df['datetime'], pnl * 100, width=0.8, color=colors, alpha=0.7, label='PnL %') + + # SL/TP thresholds + sl_global = config.get('sl_global', 0.03) + tp_global = config.get('tp_global', 0.05) + ax4.axhline(y=sl_global * 100, linestyle='--', color='red', alpha=0.7, label=f'Stop Loss ({sl_global*100:.0f}%)') + ax4.axhline(y=-sl_global * 100, linestyle='--', color='red', alpha=0.7) + ax4.axhline(y=tp_global * 100, linestyle='--', color='green', alpha=0.7, label=f'Take Profit ({tp_global*100:.0f}%)') + ax4.axhline(y=-tp_global * 100, linestyle='--', color='green', alpha=0.7) + ax4.axhline(y=0, linestyle='-', color='gray', alpha=0.5) + + ax4.set_ylabel('PnL (%)') + ax4.set_xlabel('Time') + ax4.legend(loc='upper left', fontsize=8) + ax4.grid(True, alpha=0.3) + ax4.set_title('Combined PnL (Maker + Hedge)') + + # Format x-axis + date_range = df['datetime'].max() - df['datetime'].min() + if date_range.days >= 1: + locator = mdates.AutoDateLocator(minticks=6, maxticks=12) + formatter = mdates.ConciseDateFormatter(locator) + else: + locator = mdates.HourLocator(interval=max(1, date_range.seconds // 7200)) + formatter = mdates.DateFormatter('%H:%M') + + for ax in axes: + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(formatter) + plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right') + + fig.suptitle(f'Delta Neutral Market Making - {trading_pair}', fontsize=12) + plt.tight_layout() + + buf = io.BytesIO() + plt.savefig(buf, format='png', dpi=120, bbox_inches='tight') + plt.close(fig) + buf.seek(0) + return buf + + +def _prepare_dataframe(candles: List[Dict[str, Any]]) -> pd.DataFrame: + """Convert candles list to DataFrame with datetime index.""" + df = pd.DataFrame(candles) + + # Find timestamp column + ts_col = next((c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), None) + + if ts_col: + sample = df[ts_col].iloc[0] + if isinstance(sample, (int, float)): + if sample > 10**12: # milliseconds + df['datetime'] = pd.to_datetime(df[ts_col], unit='ms') + else: # seconds + df['datetime'] = pd.to_datetime(df[ts_col], unit='s') + else: + df['datetime'] = pd.to_datetime(df[ts_col]) + else: + # Fallback: sequential dates + df['datetime'] = pd.date_range(end=pd.Timestamp.now(), periods=len(df), freq='1min') + + return df.sort_values('datetime').reset_index(drop=True) + + +def _generate_simple_chart( + config: Dict[str, Any], + candles_data: List[Dict[str, Any]], + current_price: Optional[float] = None, +) -> io.BytesIO: + """Generate simple chart when insufficient data.""" + fig, ax = plt.subplots(figsize=(10, 6)) + + trading_pair = config.get('connector_pair_maker_trading_pair', 'Unknown') + + if candles_data: + df = _prepare_dataframe(candles_data) + if 'close' in df.columns: + ax.plot(df['datetime'], pd.to_numeric(df['close'], errors='coerce'), linewidth=1.5, color='white') + + if current_price: + ax.axhline(y=current_price, linestyle='--', color='yellow', alpha=0.7) + + ax.set_title(f'Delta Neutral MM - {trading_pair} (Insufficient Data)') + ax.set_ylabel('Price') + ax.set_xlabel('Time') + ax.grid(True, alpha=0.3) + + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format='png', dpi=100, bbox_inches='tight') + plt.close(fig) + buf.seek(0) + return buf + + +def generate_preview_chart( + config: Dict[str, Any], + candles_data: List[Dict[str, Any]], + current_price: Optional[float] = None, +) -> io.BytesIO: + """Generate preview chart (smaller dimensions).""" + return generate_chart(config, candles_data, current_price) diff --git a/handlers/bots/controllers/delta_neutral_mm/config.py b/handlers/bots/controllers/delta_neutral_mm/config.py new file mode 100644 index 00000000..853b8ff0 --- /dev/null +++ b/handlers/bots/controllers/delta_neutral_mm/config.py @@ -0,0 +1,365 @@ +""" +Delta Neutral Market Making controller configuration. +""" + +from typing import Any, Dict, List, Optional, Tuple + +from .._base import ControllerField + +# Default configuration values +DEFAULTS: Dict[str, Any] = { + "controller_name": "delta_neutral_mm", + "controller_type": "generic", + "id": "", + # Exchanges + "connector_pair_maker_connector_name": "kucoin", + "connector_pair_maker_trading_pair": "SOL-USDT", + "connector_pair_hedge_connector_name": "hyperliquid_perpetual", + "connector_pair_hedge_trading_pair": "SOL-USDT", + # Candles + "candles_connector": None, + "candles_trading_pair": None, + "interval": "3m", + # MACD + "macd_fast": 21, + "macd_slow": 42, + "macd_signal": 9, + # NATR + "natr_length": 14, + # Market making levels + "buy_spreads": "1.0, 2.0, 3.0", + "sell_spreads": "1.0, 2.0, 3.0", + "order_amount_quote": 15, + "order_refresh_time": 30, + # Delta hedging + "hedge_threshold_quote": 10, + "max_delta_quote": 50, + # Hedge settings + "leverage": 1, + "position_mode": "HEDGE", + # Risk + "sl_global": 0.03, + "tp_global": 0.05, + # Timeout + "hedge_position_timeout": 3600, + # TP multiplier + "maker_tp_multiplier": 1.0, +} + + +# Field definitions +FIELDS: Dict[str, ControllerField] = { + "id": ControllerField( + name="id", + label="Config ID", + type="str", + required=True, + hint="Auto-generated", + ), + # Maker exchange + "connector_pair_maker_connector_name": ControllerField( + name="connector_pair_maker_connector_name", + label="Maker Exchange", + type="str", + required=True, + hint="Exchange for limit orders (spot preferred)", + ), + "connector_pair_maker_trading_pair": ControllerField( + name="connector_pair_maker_trading_pair", + label="Maker Pair", + type="str", + required=True, + hint="e.g. SOL-USDT", + ), + # Hedge exchange + "connector_pair_hedge_connector_name": ControllerField( + name="connector_pair_hedge_connector_name", + label="Hedge Exchange", + type="str", + required=True, + hint="Perpetual exchange for delta hedging", + ), + "connector_pair_hedge_trading_pair": ControllerField( + name="connector_pair_hedge_trading_pair", + label="Hedge Pair", + type="str", + required=True, + hint="e.g. SOL-USDT", + ), + # Candles + "candles_connector": ControllerField( + name="candles_connector", + label="Candles Connector", + type="str", + required=False, + hint="Leave empty to use maker exchange", + default=None, + ), + "candles_trading_pair": ControllerField( + name="candles_trading_pair", + label="Candles Pair", + type="str", + required=False, + hint="Leave empty to use maker pair", + default=None, + ), + "interval": ControllerField( + name="interval", + label="Candle Interval", + type="str", + required=True, + hint="e.g. 1m, 3m, 5m, 1h", + default="3m", + ), + # MACD + "macd_fast": ControllerField( + name="macd_fast", + label="MACD Fast", + type="int", + required=False, + hint="Fast EMA period", + default=21, + ), + "macd_slow": ControllerField( + name="macd_slow", + label="MACD Slow", + type="int", + required=False, + hint="Slow EMA period", + default=42, + ), + "macd_signal": ControllerField( + name="macd_signal", + label="MACD Signal", + type="int", + required=False, + hint="Signal line period", + default=9, + ), + # NATR + "natr_length": ControllerField( + name="natr_length", + label="NATR Length", + type="int", + required=False, + hint="Normalized ATR period", + default=14, + ), + # Spreads + "buy_spreads": ControllerField( + name="buy_spreads", + label="Buy Spreads", + type="str", + required=False, + hint="Comma-separated NATR multiples (e.g. 1.0,2.0,3.0)", + default="1.0,2.0,3.0", + ), + "sell_spreads": ControllerField( + name="sell_spreads", + label="Sell Spreads", + type="str", + required=False, + hint="Comma-separated NATR multiples", + default="1.0,2.0,3.0", + ), + "order_amount_quote": ControllerField( + name="order_amount_quote", + label="Order Amount (USDT)", + type="float", + required=False, + hint="Amount per level in quote currency", + default=15, + ), + "order_refresh_time": ControllerField( + name="order_refresh_time", + label="Refresh Time (s)", + type="int", + required=False, + hint="Cancel unfilled orders after this many seconds", + default=30, + ), + # Delta hedging + "hedge_threshold_quote": ControllerField( + name="hedge_threshold_quote", + label="Hedge Threshold (USDT)", + type="float", + required=False, + hint="Hedge when delta exceeds this value", + default=10, + ), + "max_delta_quote": ControllerField( + name="max_delta_quote", + label="Max Delta (USDT)", + type="float", + required=False, + hint="Emergency hedge at this delta", + default=50, + ), + # Leverage + "leverage": ControllerField( + name="leverage", + label="Leverage", + type="int", + required=False, + hint="1x recommended", + default=1, + ), + "position_mode": ControllerField( + name="position_mode", + label="Position Mode", + type="str", + required=False, + hint="HEDGE or ONEWAY", + default="HEDGE", + ), + # Risk + "sl_global": ControllerField( + name="sl_global", + label="Stop Loss", + type="float", + required=False, + hint="Emergency exit at this loss (e.g. 0.03 = 3%)", + default=0.03, + ), + "tp_global": ControllerField( + name="tp_global", + label="Take Profit", + type="float", + required=False, + hint="Emergency exit at this profit", + default=0.05, + ), + "hedge_position_timeout": ControllerField( + name="hedge_position_timeout", + label="Hedge Timeout (s)", + type="int", + required=False, + hint="Close hedge positions after this many seconds (0=disabled)", + default=3600, + ), + "maker_tp_multiplier": ControllerField( + name="maker_tp_multiplier", + label="Maker TP Multiplier", + type="float", + required=False, + hint="Take profit multiplier for maker orders", + default=1.0, + ), +} + + +FIELD_ORDER: List[str] = [ + "id", + "connector_pair_maker_connector_name", + "connector_pair_maker_trading_pair", + "connector_pair_hedge_connector_name", + "connector_pair_hedge_trading_pair", + "candles_connector", + "candles_trading_pair", + "interval", + "macd_fast", + "macd_slow", + "macd_signal", + "natr_length", + "buy_spreads", + "sell_spreads", + "order_amount_quote", + "order_refresh_time", + "hedge_threshold_quote", + "max_delta_quote", + "leverage", + "position_mode", + "sl_global", + "tp_global", + "hedge_position_timeout", + "maker_tp_multiplier", +] + + +EDITABLE_FIELDS: List[str] = [ + "connector_pair_maker_connector_name", + "connector_pair_maker_trading_pair", + "connector_pair_hedge_connector_name", + "connector_pair_hedge_trading_pair", + "candles_connector", + "candles_trading_pair", + "interval", + "buy_spreads", + "sell_spreads", + "order_amount_quote", + "order_refresh_time", + "hedge_threshold_quote", + "max_delta_quote", + "leverage", + "sl_global", + "tp_global", + "hedge_position_timeout", + "maker_tp_multiplier", +] + +def get_flat_fields(config: Dict[str, Any]) -> Dict[str, Any]: + """Estrae i campi piatti, convertendo eventuali liste di spreads in stringhe.""" + flat = dict(config) + for key in ("buy_spreads", "sell_spreads"): + if key in flat and isinstance(flat[key], list): + flat[key] = ",".join(str(x) for x in flat[key]) + return flat + + +def apply_flat_fields(config: Dict[str, Any], updates: Dict[str, Any]) -> None: + """Applica gli aggiornamenti, mantenendo i spreads come stringhe.""" + for key, value in updates.items(): + if key in ("buy_spreads", "sell_spreads") and isinstance(value, list): + config[key] = ",".join(str(x) for x in value) + else: + config[key] = value + +def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + """Validate delta neutral MM configuration.""" + required = [ + "connector_pair_maker_connector_name", + "connector_pair_maker_trading_pair", + "connector_pair_hedge_connector_name", + "connector_pair_hedge_trading_pair", + ] + for field in required: + if not config.get(field): + return False, f"Missing required field: {field}" + + # Validate spreads + buy_spreads = config.get("buy_spreads", []) + sell_spreads = config.get("sell_spreads", []) + if isinstance(buy_spreads, str): + buy_spreads = [float(x.strip()) for x in buy_spreads.split(",")] + if isinstance(sell_spreads, str): + sell_spreads = [float(x.strip()) for x in sell_spreads.split(",")] + + if not buy_spreads or not sell_spreads: + return False, "Buy and sell spreads must have at least one level" + + return True, None + + +def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + """Generate unique config ID.""" + 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) + + maker = config.get("connector_pair_maker_connector_name", "unknown") + hedge = config.get("connector_pair_hedge_connector_name", "unknown") + pair = config.get("connector_pair_maker_trading_pair", "UNKNOWN").upper() + + maker_clean = maker.replace("_perpetual", "").replace("_spot", "") + hedge_clean = hedge.replace("_perpetual", "").replace("_spot", "") + + return f"{seq}_dnmm_{maker_clean}_{hedge_clean}_{pair}" diff --git a/handlers/bots/controllers/dman_v3/__init__.py b/handlers/bots/controllers/dman_v3/__init__.py new file mode 100644 index 00000000..bc96d77b --- /dev/null +++ b/handlers/bots/controllers/dman_v3/__init__.py @@ -0,0 +1,42 @@ +"""DMan V3 Controller Module - Mean reversion with Bollinger Bands + DCA execution.""" + +import io +from typing import Any, Dict, List, Optional, Tuple + +from .._base import BaseController, ControllerField +from .chart import generate_chart, generate_preview_chart +from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config + + +class DManV3Controller(BaseController): + controller_type = "dman_v3" + display_name = "DMan V3" + description = "Mean reversion with Bollinger Bands + DCA" + + @classmethod + def get_defaults(cls) -> Dict[str, Any]: + return DEFAULTS.copy() + + @classmethod + def get_fields(cls) -> Dict[str, ControllerField]: + return FIELDS + + @classmethod + def get_field_order(cls) -> List[str]: + return FIELD_ORDER + + @classmethod + def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + 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: + return generate_chart(config, candles_data, current_price) + + @classmethod + def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + return generate_id(config, existing_configs) + + +__all__ = ["DManV3Controller", "DEFAULTS", "FIELDS", "FIELD_ORDER", "EDITABLE_FIELDS", + "validate_config", "generate_id", "generate_chart", "generate_preview_chart"] diff --git a/handlers/bots/controllers/dman_v3/analysis.py b/handlers/bots/controllers/dman_v3/analysis.py new file mode 100644 index 00000000..b6529351 --- /dev/null +++ b/handlers/bots/controllers/dman_v3/analysis.py @@ -0,0 +1,315 @@ +""" +DMan V3 analysis utilities. + +Calculates suggested parameters from candle data: +- bb_long_threshold β†’ based on historical BBP distribution +- bb_short_threshold β†’ based on historical BBP distribution +- dca_spreads β†’ based on NATR (volatility-scaled) +""" + +from typing import Any, Dict, List, Optional, Tuple + + +def calculate_bbp_series( + candles: List[Dict[str, Any]], + bb_length: int = 20, + bb_std: float = 2.0, +) -> List[float]: + """ + Calculate Bollinger Band Percent (BBP) for each candle. + + BBP = (close - lower) / (upper - lower) + - BBP = 0.0 β†’ price at lower band + - BBP = 0.5 β†’ price at middle (SMA) + - BBP = 1.0 β†’ price at upper band + - BBP < 0 β†’ price below lower band (oversold) + - BBP > 1 β†’ price above upper band (overbought) + + Args: + candles: List of candle dicts with 'close' key + bb_length: Bollinger Bands period + bb_std: Standard deviations + + Returns: + List of BBP values (same length as candles, None for initial period) + """ + import math + + closes = [] + for c in candles: + close = c.get("close") or c.get("c") + if close is not None: + closes.append(float(close)) + + if len(closes) < bb_length: + return [] + + bbp_values = [] + for i in range(len(closes)): + if i < bb_length - 1: + continue + window = closes[i - bb_length + 1: i + 1] + sma = sum(window) / bb_length + variance = sum((x - sma) ** 2 for x in window) / bb_length + std = math.sqrt(variance) + upper = sma + bb_std * std + lower = sma - bb_std * std + band_width = upper - lower + if band_width > 0: + bbp = (closes[i] - lower) / band_width + else: + bbp = 0.5 + bbp_values.append(bbp) + + return bbp_values + + +def calculate_natr(candles: List[Dict[str, Any]], period: int = 14) -> Optional[float]: + """Calculate Normalized ATR from candles.""" + if not candles or len(candles) < period + 1: + return None + + true_ranges = [] + for i in range(1, len(candles)): + high = float(candles[i].get("high", 0) or 0) + low = float(candles[i].get("low", 0) or 0) + prev_close = float(candles[i - 1].get("close", 0) or 0) + if not all([high, low, prev_close]): + continue + tr = max(high - low, abs(high - prev_close), abs(low - prev_close)) + true_ranges.append(tr) + + if len(true_ranges) < period: + return None + + atr = sum(true_ranges[-period:]) / period + current_close = float(candles[-1].get("close", 0) or 0) + if current_close <= 0: + return None + + return atr / current_close + + +def suggest_dca_spreads(natr: float, num_levels: int = 4) -> List[float]: + """ + Suggest DCA spread levels based on NATR. + + Logic: + - Level 1 (closest): ~0.5x NATR + - Level 2: ~2x NATR + - Level 3: ~5x NATR + - Level 4 (deepest): ~10x NATR + + These multipliers ensure meaningful distance between orders + proportional to the market's typical daily range. + + Args: + natr: Normalized ATR as decimal (e.g. 0.005 = 0.5%) + num_levels: Number of DCA levels (2-4) + + Returns: + List of spread values sorted ascending + """ + multipliers = [0.5, 2.0, 5.0, 10.0][:num_levels] + spreads = [round(natr * m, 4) for m in multipliers] + # Ensure minimum reasonable spreads + spreads = [max(s, 0.0005) for s in spreads] + return spreads + + +def suggest_bb_thresholds( + bbp_values: List[float], + long_percentile: float = 15.0, + short_percentile: float = 85.0, +) -> Tuple[float, float]: + """ + Suggest bb_long_threshold and bb_short_threshold based on + historical BBP distribution. + + Logic: + - bb_long_threshold: BBP value at the Nth percentile from below + β†’ "enter LONG when price is in the bottom X% of its historical range" + - bb_short_threshold: BBP value at the Nth percentile from above + β†’ "enter SHORT when price is in the top X% of its historical range" + + Default: bottom 15% / top 15% of observations + β†’ conservative entry, avoids chasing + + Args: + bbp_values: Historical BBP series + long_percentile: Enter LONG below this percentile (default 15%) + short_percentile: Enter SHORT above this percentile (default 85%) + + Returns: + Tuple of (bb_long_threshold, bb_short_threshold) + """ + if not bbp_values: + return 0.0, 1.0 + + sorted_bbp = sorted(bbp_values) + n = len(sorted_bbp) + + long_idx = int(n * long_percentile / 100) + short_idx = int(n * short_percentile / 100) + + long_idx = max(0, min(long_idx, n - 1)) + short_idx = max(0, min(short_idx, n - 1)) + + long_threshold = round(sorted_bbp[long_idx], 3) + short_threshold = round(sorted_bbp[short_idx], 3) + + return long_threshold, short_threshold + + +def analyze_candles_for_dman( + candles: List[Dict[str, Any]], + bb_length: int = 20, + bb_std: float = 2.0, + natr_period: int = 14, + dca_levels: int = 4, +) -> Dict[str, Any]: + """ + Full analysis of candle data for DMan V3 parameter suggestion. + + Returns a dict with: + - bbp_current: Current BBP value + - bb_upper/middle/lower: Current BB levels + - natr: Normalized ATR + - suggested_long_threshold: Suggested bb_long_threshold + - suggested_short_threshold: Suggested bb_short_threshold + - suggested_dca_spreads: Suggested DCA spreads list + - pct_below_lower: % of time price was below lower band + - pct_above_upper: % of time price was above upper band + - analysis_candles: Number of candles used + """ + import math + + result = { + "bbp_current": None, + "bb_upper": None, + "bb_middle": None, + "bb_lower": None, + "natr": None, + "suggested_long_threshold": 0.0, + "suggested_short_threshold": 1.0, + "suggested_dca_spreads": [0.001, 0.018, 0.15, 0.25], + "pct_below_lower": 0.0, + "pct_above_upper": 0.0, + "analysis_candles": len(candles), + } + + if not candles or len(candles) < bb_length + natr_period: + return result + + # Calculate BBP series + bbp_values = calculate_bbp_series(candles, bb_length, bb_std) + if not bbp_values: + return result + + result["bbp_current"] = round(bbp_values[-1], 3) + result["analysis_candles"] = len(candles) + + # % time outside bands + below = sum(1 for v in bbp_values if v < 0) + above = sum(1 for v in bbp_values if v > 1) + n = len(bbp_values) + result["pct_below_lower"] = round(below / n * 100, 1) + result["pct_above_upper"] = round(above / n * 100, 1) + + # Current BB levels + closes = [float(c.get("close") or c.get("c") or 0) for c in candles] + if len(closes) >= bb_length: + window = closes[-bb_length:] + sma = sum(window) / bb_length + variance = sum((x - sma) ** 2 for x in window) / bb_length + std = math.sqrt(variance) + result["bb_upper"] = round(sma + bb_std * std, 6) + result["bb_middle"] = round(sma, 6) + result["bb_lower"] = round(sma - bb_std * std, 6) + + # NATR + natr = calculate_natr(candles, natr_period) + result["natr"] = natr + + # Suggested thresholds from BBP distribution + long_thr, short_thr = suggest_bb_thresholds(bbp_values) + result["suggested_long_threshold"] = long_thr + result["suggested_short_threshold"] = short_thr + + # Suggested DCA spreads from NATR + if natr and natr > 0: + result["suggested_dca_spreads"] = suggest_dca_spreads(natr, dca_levels) + + return result + + +def format_dman_analysis(analysis: Dict[str, Any]) -> str: + """ + Format analysis results for display in wizard final step. + + Returns a string block to append to the config text. + """ + lines = [] + natr = analysis.get("natr") + bbp = analysis.get("bbp_current") + bb_upper = analysis.get("bb_upper") + bb_middle = analysis.get("bb_middle") + bb_lower = analysis.get("bb_lower") + pct_below = analysis.get("pct_below_lower", 0) + pct_above = analysis.get("pct_above_upper", 0) + n_candles = analysis.get("analysis_candles", 0) + + lines.append(f"BB analysis ({n_candles} candles):") + if bb_upper and bb_middle and bb_lower: + lines.append(f" Upper: {bb_upper:.4f} | Mid: {bb_middle:.4f} | Lower: {bb_lower:.4f}") + if bbp is not None: + pos = "oversold" if bbp < 0.2 else ("overbought" if bbp > 0.8 else "neutral") + lines.append(f" BBP now: {bbp:.3f} ({pos})") + if natr: + lines.append(f" NATR(14): {natr*100:.3f}%") + lines.append(f" % below lower band: {pct_below:.1f}%") + lines.append(f" % above upper band: {pct_above:.1f}%") + + lines.append("") + lines.append(f" β†’ bb_long_threshold: {analysis['suggested_long_threshold']}") + lines.append(f" β†’ bb_short_threshold: {analysis['suggested_short_threshold']}") + spreads = analysis.get("suggested_dca_spreads", []) + if spreads: + lines.append(f" β†’ dca_spreads: {','.join(str(s) for s in spreads)}") + + return "\n".join(lines) + +def get_dca_strategy_suggestions(natr: float) -> Dict[str, Dict[str, Any]]: + if not natr: + natr = 0.01 + + # AUTO: Usa la logica suggerita dall'analisi delle candele + auto_spreads = suggest_dca_spreads(natr, 4) + + return { + "scalping": { + "label": "Target: Scalping (Ordini vicini e costanti)", + "dca_spreads": [round(natr*0.2, 4), round(natr*0.4, 4), round(natr*0.6, 4), round(natr*0.9, 4)], + "dca_amounts_pct": [0.25, 0.25, 0.25, 0.25] # Distribuzione piatta + }, + "martingale": { + "label": "Target: Martingala (Raddoppio)", + "dca_spreads": [round(natr*0.5, 4), round(natr*1.2, 4), round(natr*2.5, 4), round(natr*5.0, 4)], + "dca_amounts_pct": [0.10, 0.20, 0.30, 0.40] # PiΓΉ capitale sui livelli profondi + }, + "standard": { + "label": "Target: Standard (Bilanciato)", + "dca_spreads": [round(natr*1.0, 4), round(natr*2.0, 4), round(natr*3.0, 4), round(natr*4.0, 4)], + "dca_amounts_pct": [0.20, 0.20, 0.30, 0.30] + }, + "conservative": { + "label": "Target: Conservativo (Protezione)", + "dca_spreads": [round(natr*2.0, 4), round(natr*5.0, 4), round(natr*10.0, 4), round(natr*15.0, 4)], + "dca_amounts_pct": [0.40, 0.30, 0.20, 0.10] # PiΓΉ capitale vicino, meno se crolla tutto + }, + "auto": { + "label": "Target: Auto (Analisi NATR)", + "dca_spreads": auto_spreads, + "dca_amounts_pct": [0.25, 0.25, 0.25, 0.25] + } + } diff --git a/handlers/bots/controllers/dman_v3/chart.py b/handlers/bots/controllers/dman_v3/chart.py new file mode 100644 index 00000000..f4ce38c4 --- /dev/null +++ b/handlers/bots/controllers/dman_v3/chart.py @@ -0,0 +1,311 @@ +""" +DMan V3 chart generation. + +4 panels: + 1. Price – candlesticks + BB + MA20/50/EMA9 + DCA lines + 2. Volume – colored bars + 3. RSI – RSI indicator with overbought/oversold zones + 4. BBP – Bollinger Band Percent with long/short thresholds +""" + +import io +import time +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +from matplotlib.patches import Rectangle +plt.style.use('dark_background') + +def generate_chart(config, candles_data, current_price=None): + if not candles_data or len(candles_data) < 5: + return _generate_simple_chart(candles_data, current_price) + + df = _prepare_dataframe(candles_data) + full_df = df.copy() + MAX_VISIBLE_CANDLES = 96 + for col in ['open', 'high', 'low', 'close', 'volume']: + full_df[col] = pd.to_numeric(full_df.get(col, 0), errors='coerce').fillna(0) + + # ── INDICATORI ────────────────────────────────────────────────── + full_df['ma20'] = full_df['close'].rolling(20).mean() + full_df['ma50'] = full_df['close'].rolling(50).mean() + full_df['ema9'] = full_df['close'].ewm(span=9).mean() + bb_length = int(config.get('bb_length', 20)) + bb_std_val = float(config.get('bb_std', 2.0)) + rolling = full_df['close'].rolling(bb_length) + full_df['bb_mid'] = rolling.mean() + bb_std_series = rolling.std() + full_df['bb_upper'] = (full_df['bb_mid'] + bb_std_val * bb_std_series) + full_df['bb_lower'] = (full_df['bb_mid'] - bb_std_val * bb_std_series) + denom = (full_df['bb_upper'] - full_df['bb_lower']).replace(0, np.nan) + full_df['bbp'] = ((full_df['close'] - full_df['bb_lower']) / denom).clip(-1, 2).fillna(0.5) + full_df['rsi'] = _calc_rsi(full_df['close']) + # dataset visualizzato + df = full_df.tail(MAX_VISIBLE_CANDLES).copy() + + # ── FIGURA ────────────────────────────────────────────────────── + fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1, figsize=(22, 14), sharex=True, gridspec_kw={ + 'height_ratios': [4.5, 1.2, 1.3, 1.5] + } + ) + + fig.patch.set_facecolor('#111111') + for ax in [ax1, ax2, ax3, ax4]: + ax.set_facecolor('#111111') + ax.tick_params(colors='white') + ax.yaxis.label.set_color('white') + ax.spines['bottom'].set_color('#444') + ax.spines['top'].set_color('#444') + ax.spines['left'].set_color('#444') + ax.spines['right'].set_color('#444') + dates = mdates.date2num(df['datetime']) + + if len(dates) > 1: + candle_width = (dates[1] - dates[0]) * 0.85 + volume_width = (dates[1] - dates[0]) * 0.85 + else: + candle_width = volume_width = 0.0005 + + # ── PANNELLO 1: PREZZO ────────────────────────────────────────── + for i in range(len(df)): + o, h, l, c = df.iloc[i][['open', 'high', 'low', 'close']] + color = '#2ecc71' if c >= o else '#e74c3c' + ax1.plot([dates[i], dates[i]], [l, h], color=color, linewidth=1) + ax1.add_patch(Rectangle( + (dates[i] - candle_width / 2, min(o, c)), + candle_width, abs(c - o) or 1e-8, + color=color + )) + + ax1.plot(df['datetime'], df['ma20'], label='MA20', linewidth=1.4, color='#f39c12') + ax1.plot(df['datetime'], df['ma50'], label='MA50', linewidth=1.4, color='#3498db') + ax1.plot(df['datetime'], df['ema9'], label='EMA9', linewidth=1.4, color='#9b59b6') + ax1.plot(df['datetime'], df['bb_upper'], '--', linewidth=1.1, color='#aaaaaa', alpha=0.95,label='BB Upper') + ax1.plot(df['datetime'], df['bb_mid'], ':', linewidth=1.0, color='#aaaaaa', alpha=0.95, label='BB Mid') + ax1.plot(df['datetime'], df['bb_lower'], '--', linewidth=1.1, color='#aaaaaa', alpha=0.95, label='BB Lower') + ax1.fill_between(df['datetime'], df['bb_lower'], df['bb_upper'], color='#aaaaaa', alpha=0.18) + + if current_price: + ax1.axhline(y=current_price, linestyle='--', alpha=0.8, color='gold', linewidth=1.3, label='Price') + + # DCA lines + spreads = config.get("dca_spreads", "") + if isinstance(spreads, str) and current_price: + try: + spreads = [float(x.strip()) for x in spreads.split(",")] + for s in spreads: + ax1.axhline(current_price * (1 - s), linestyle=':', alpha=0.4, color='#7f8c8d') + ax1.axhline(current_price * (1 + s), linestyle=':', alpha=0.2, color='#7f8c8d') + except Exception: + pass + + legend1 = ax1.legend(loc='upper left',fontsize=9,ncol=3,framealpha=0) + for text in legend1.get_texts(): + text.set_color('white') + ax1.set_ylabel('Price') + ax1.set_xlim(df['datetime'].min(), df['datetime'].max()) + + # ── PANNELLO 2: VOLUME ────────────────────────────────────────── + vol_colors = [ + '#2ecc71' if df['close'].iloc[i] >= df['open'].iloc[i] else '#e74c3c' + for i in range(len(df)) + ] + ax2.bar(dates, df['volume'], width=volume_width, color=vol_colors, alpha=0.7) + ax2.set_ylabel('Volume') + + # ── PANNELLO 3: RSI ───────────────────────────────────────────── + ax3.plot(df['datetime'], df['rsi'], linewidth=1.5, color='steelblue') + + ax3.axhline(70, linestyle='--', color='red', alpha=0.7, label='Overbought (70)') + ax3.axhline(30, linestyle='--', color='green', alpha=0.7, label='Oversold (30)') + ax3.axhline(50, linestyle=':', color='#7f8c8d', alpha=0.5) + + ax3.fill_between(df['datetime'], 30, 70, alpha=0.18, color='#aaaaaa') + ax3.set_ylim(10, 90) + legend3 = ax3.legend(loc='upper left', fontsize=9, framealpha=0) + for text in legend3.get_texts(): + text.set_color('white') + ax3.set_ylabel('RSI') + + # ── PANNELLO 4: BBP ───────────────────────────────────────────── + ax4.plot(df['datetime'], df['bbp'], linewidth=1.5, color='steelblue') + long_thr = float(config.get('bb_long_threshold', 0.0)) + short_thr = float(config.get('bb_short_threshold', 1.0)) + + ax4.axhline(long_thr, linestyle='--', color='green', alpha=0.8, label=f'Long {long_thr}') + ax4.axhline(short_thr, linestyle='--', color='red', alpha=0.8, label=f'Short {short_thr}') + ax4.axhline(0, linestyle=':', color='#7f8c8d', alpha=0.5) + ax4.axhline(1, linestyle=':', color='#7f8c8d', alpha=0.5) + + # evidenzia zone di segnale + ax4.fill_between(df['datetime'], -1, long_thr, alpha=0.22, color='#00ff88') + ax4.fill_between(df['datetime'], short_thr, 2, alpha=0.22, color='#ff4d6d') + + legend4 = ax4.legend(loc='upper left', fontsize=9, framealpha=0) + for text in legend4.get_texts(): + text.set_color('white') + ax4.set_ylabel('BBP') + + # ── FIX ASSE X BASATO SUL TIMEFRAME ─────────────────────────────── + + interval = config.get('interval', '5m') + + if interval == '1m': + locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45]) + formatter = mdates.DateFormatter('%H:%M') + minor_locator = mdates.MinuteLocator(interval=5) + + elif interval == '5m': + locator = mdates.HourLocator(interval=1) + formatter = mdates.DateFormatter('%H:%M') + minor_locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45]) + + elif interval == '15m': + + locator = mdates.HourLocator(interval=3) + formatter = mdates.DateFormatter('%d %b - %H:%M') + minor_locator = mdates.HourLocator(interval=1) + + elif interval == '1h': + + locator = mdates.HourLocator(interval=12) + formatter = mdates.DateFormatter('%d %b - %H:%M') + minor_locator = mdates.HourLocator(interval=3) + + elif interval == '8h': + + locator = mdates.DayLocator(interval=4) + formatter = mdates.DateFormatter('%b%d') + minor_locator = mdates.DayLocator(interval=1) + + else: + + locator = mdates.AutoDateLocator(minticks=6, maxticks=12) + formatter = mdates.ConciseDateFormatter(locator) + minor_locator = None + + # ── APPLICA A TUTTI GLI ASSI ────────────────────────────────────── + + ax1.tick_params(labelbottom=False) + ax2.tick_params(labelbottom=False) + ax3.tick_params(labelbottom=False) + + for ax in [ax1, ax2, ax3, ax4]: + + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(formatter) + + if minor_locator: + ax.xaxis.set_minor_locator(minor_locator) + + plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center') + + # grid verticale + ax.grid(True, which='major', axis='x', linestyle='--', alpha=0.15) + + # grid orizzontale + ax.grid( True, which='major', axis='y', alpha=0.25) + + # minor grid + ax.grid(True, which='minor', axis='y',alpha=0.06) + # ── TITOLO ────────────────────────────────────────────────────── + interval = config.get('interval', '3m') + fig.suptitle( + f"{config.get('trading_pair', 'Unknown')} - DMan V3 " + f"(BB{bb_length} | RSI14 | {interval})", + fontsize=13, color='white' + ) + + plt.subplots_adjust(hspace=0.05, top=0.94, bottom=0.06) + buf = io.BytesIO() + plt.savefig(buf, format='png', dpi=120, bbox_inches='tight') + plt.close(fig) + buf.seek(0) + return buf + + +# ── HELPERS ───────────────────────────────────────────────────────── + +def _prepare_dataframe(candles, timezone=None): + if timezone is None: + # Prende il fuso orario del sistema + timezone = time.tzname[0] + df = pd.DataFrame(candles) + + # Cerca colonna timestamp + ts_col = next((c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), None) + + if ts_col: + # Converti timestamp + sample = df[ts_col].iloc[0] + if isinstance(sample, (int, float)): + # Determina se Γ¨ millisecondi o secondi + if sample > 10**12: # nanosecondi + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='ns', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + elif sample > 10**10: # millisecondi (dopo il 1970) + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='ms', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + else: # secondi + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='s', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + else: + df['datetime'] = (pd.to_datetime(df[ts_col], utc=True).dt.tz_convert(timezone).dt.tz_localize(None)) + else: + # Fallback: crea date sequenziali usando l'intervallo dalla config + # NOTA: questo Γ¨ un fallback, idealmente dovresti avere timestamp reali + freq = config.get('interval', '5m') if 'config' in locals() else '5m' + df['datetime'] = pd.date_range( + end=pd.Timestamp.now(), + periods=len(df), + freq=freq + ) + + return df.sort_values('datetime').reset_index(drop=True) + + + +def _calc_rsi(series, period=14): + delta = series.diff() + gain = (delta.where(delta > 0, 0)).rolling(period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(period).mean() + rs = gain / loss.replace(0, np.nan) + rsi = 100 - (100 / (1 + rs)) + return rsi.bfill().fillna(50) + + +def _generate_simple_chart(candles_data, current_price): + if not candles_data: + return io.BytesIO() + df = _prepare_dataframe(candles_data) + MAX_VISIBLE_CANDLES = 96 + if len(df) > MAX_VISIBLE_CANDLES: + df = df.tail(MAX_VISIBLE_CANDLES).reset_index(drop=True) + fig, ax = plt.subplots(figsize=(10, 6)) + ax.plot(df['datetime'], pd.to_numeric(df.get('close', pd.Series()), errors='coerce')) + if current_price: + ax.axhline(y=current_price, linestyle='--') + locator = mdates.AutoDateLocator(minticks=6, maxticks=12) + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator)) + plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center') + plt.subplots_adjust(hspace=0.05, top=0.94, bottom=0.06) + buf = io.BytesIO() + plt.savefig(buf, format='png') + plt.close(fig) + buf.seek(0) + return buf + + +def generate_preview_chart(config, candles_data, current_price=None): + return generate_chart(config, candles_data, current_price) diff --git a/handlers/bots/controllers/dman_v3/config.py b/handlers/bots/controllers/dman_v3/config.py new file mode 100644 index 00000000..4fa3f2ca --- /dev/null +++ b/handlers/bots/controllers/dman_v3/config.py @@ -0,0 +1,175 @@ +""" +DMan V3 controller configuration. + +Mean reversion strategy using Bollinger Bands to determine direction, +with DCA execution to enter positions at multiple levels. +""" + +from typing import Any, Dict, List, Optional, Tuple + +from .._base import ControllerField + +DEFAULTS: Dict[str, Any] = { + "controller_name": "dman_v3", + "controller_type": "directional_trading", + "id": "", + # Base fields from ControllerConfigBase + "manual_kill_switch": False, + # Connector + "connector_name": "", + "trading_pair": "", + "total_amount_quote": 1000, + "leverage": 1, + "position_mode": "HEDGE", + # DirectionalTradingControllerConfigBase fields + "max_executors_per_side": 1, + "cooldown_time": 60, + "stop_loss": 0.05, + "take_profit": 0.03, + "take_profit_order_type": 2, + "time_limit": None, + # Trailing stop as object (matches TrailingStop dataclass) + "trailing_stop": { + "activation_price": 0.015, + "trailing_delta": 0.005, + }, + # Candles config + "candles_connector": "", + "candles_trading_pair": "", + "interval": "5m", + # Bollinger Bands + "bb_length": 100, + "bb_std": 2.0, + "bb_long_threshold": 0.0, + "bb_short_threshold": 1.0, + # DCA + "dca_spreads": "0.001,0.018,0.15,0.25", + "dca_amounts_pct": "0.25,0.25,0.25,0.25", + "dynamic_order_spread": False, + "dynamic_target": False, + "activation_bounds": None, +} + +FIELDS: Dict[str, ControllerField] = { + "id": ControllerField(name="id", label="Config ID", type="str", required=True, hint="Auto-generated"), + "connector_name": ControllerField(name="connector_name", label="Connector", type="str", required=True, hint="Exchange connector"), + "trading_pair": ControllerField(name="trading_pair", label="Trading Pair", type="str", required=True, hint="e.g. BTC-USDT"), + "leverage": ControllerField(name="leverage", label="Leverage", type="int", required=True, hint="e.g. 1, 5, 10", default=1), + "position_mode": ControllerField(name="position_mode", label="Position Mode", type="str", required=False, hint="HEDGE or ONEWAY", default="HEDGE"), + "total_amount_quote": ControllerField(name="total_amount_quote", label="Total Amount (Quote)", type="float", required=True, hint="e.g. 1000 USDT"), + "max_executors_per_side": ControllerField(name="max_executors_per_side", label="Max Executors/Side", type="int", required=False, hint="Max concurrent positions per side (default: 1)", default=1), + "cooldown_time": ControllerField(name="cooldown_time", label="Cooldown Time (s)", type="int", required=False, hint="Seconds between new executors (default: 60)", default=60), + "stop_loss": ControllerField(name="stop_loss", label="Stop Loss", type="float", required=False, hint="Stop loss % (e.g. 0.05 = 5%)", default=0.05), + "take_profit": ControllerField(name="take_profit", label="Take Profit", type="float", required=False, hint="Take profit % (e.g. 0.03 = 3%)", default=0.03), + "take_profit_order_type": ControllerField(name="take_profit_order_type", label="TP Order Type", type="int", required=False, hint="1=Market, 2=Limit, 3=Limit Maker", default=2), + "time_limit": ControllerField(name="time_limit", label="Time Limit (s)", type="int", required=False, hint="Max executor lifetime in seconds (None = no limit)", default=None), + "candles_connector": ControllerField(name="candles_connector", label="Candles Connector", type="str", required=False, hint="Leave empty to use same as connector", default=""), + "candles_trading_pair": ControllerField(name="candles_trading_pair", label="Candles Pair", type="str", required=False, hint="Leave empty to use same as trading pair", default=""), + "interval": ControllerField(name="interval", label="Candle Interval", type="str", required=True, hint="e.g. 1m, 5m, 1h, 8h", default="5m"), + "bb_length": ControllerField(name="bb_length", label="BB Length", type="int", required=False, hint="Bollinger Bands period (default: 100)", default=100), + "bb_std": ControllerField(name="bb_std", label="BB Std Dev", type="float", required=False, hint="Standard deviations (default: 2.0)", default=2.0), + "bb_long_threshold": ControllerField(name="bb_long_threshold", label="BB Long Threshold", type="float", required=False, hint="BBP below this β†’ LONG signal (default: 0.0)", default=0.0), + "bb_short_threshold": ControllerField(name="bb_short_threshold", label="BB Short Threshold", type="float", required=False, hint="BBP above this β†’ SHORT signal (default: 1.0)", default=1.0), + "dca_spreads": ControllerField(name="dca_spreads", label="DCA Spreads", type="str", required=True, hint="Comma-separated (e.g. 0.001,0.018,0.15,0.25)", default="0.001,0.018,0.15,0.25"), + "dca_amounts_pct": ControllerField(name="dca_amounts_pct", label="DCA Amounts %", type="str", required=False, hint="Comma-separated %, empty = equal distribution", default=""), + "dynamic_order_spread": ControllerField(name="dynamic_order_spread", label="Dynamic Spread", type="bool", required=False, hint="Scale spreads with BB width", default=False), + "dynamic_target": ControllerField(name="dynamic_target", label="Dynamic Target", type="bool", required=False, hint="Scale TP/SL with BB width", default=False), + "activation_bounds": ControllerField(name="activation_bounds", label="Activation Bounds", type="float", required=False, hint="e.g. 0.01 (1%) - None to disable", default=None), + "manual_kill_switch": ControllerField(name="manual_kill_switch", label="Kill Switch", type="bool", required=False, hint="Manual kill switch", default=False), +} + +FIELD_ORDER: List[str] = [ + "id", "connector_name", "trading_pair", "leverage", "position_mode", + "total_amount_quote", "max_executors_per_side", "cooldown_time", + "stop_loss", "take_profit", "take_profit_order_type", "time_limit", + "candles_connector", "candles_trading_pair", "interval", + "bb_length", "bb_std", "bb_long_threshold", "bb_short_threshold", + "dca_spreads", "dca_amounts_pct", + "dynamic_order_spread", "dynamic_target", + "activation_bounds", "manual_kill_switch", +] + +EDITABLE_FIELDS: List[str] = [ + "connector_name", "trading_pair", "total_amount_quote", "leverage", + "max_executors_per_side", "cooldown_time", + "stop_loss", "take_profit", "take_profit_order_type", + "trailing_stop_activation", "trailing_stop_delta", + "candles_connector", "candles_trading_pair", "interval", + "bb_length", "bb_std", "bb_long_threshold", "bb_short_threshold", + "dca_spreads", "dca_amounts_pct", + "dynamic_order_spread", "dynamic_target", "activation_bounds", +] + +def get_flat_fields(config: Dict[str, Any]) -> Dict[str, Any]: + """Estrae solo i campi annidati (trailing_stop) in formato piatto.""" + trailing = config.get("trailing_stop", {}) + # Partiamo dai campi giΓ  presenti nel config (che sono giΓ  piatti) + flat = dict(config) # copia superficiale + # Sostituiamo i due campi annidati con le loro versioni piatte + flat["trailing_stop_activation"] = trailing.get("activation_price", 0.015) + flat["trailing_stop_delta"] = trailing.get("trailing_delta", 0.005) + # Rimuoviamo il dizionario originale perchΓ© non serve nella visualizzazione piatta + flat.pop("trailing_stop", None) + return flat + + +def apply_flat_fields(config: Dict[str, Any], updates: Dict[str, Any]) -> None: + """Applica gli aggiornamenti, riconvertendo i due campi nel dizionario trailing_stop.""" + for key, value in updates.items(): + if key == "trailing_stop_activation": + if "trailing_stop" not in config: + config["trailing_stop"] = {} + config["trailing_stop"]["activation_price"] = value + elif key == "trailing_stop_delta": + if "trailing_stop" not in config: + config["trailing_stop"] = {} + config["trailing_stop"]["trailing_delta"] = value + else: + config[key] = value + + +def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + # Identifica il connettore principale + connector = config.get("connector_name", "").lower() + is_spot = "spot" in connector or not ("perpetual" in connector or "margin" in connector) + + # --- FIX AUTOMATICO PER IL GRAFICO --- + # Se il connettore delle candele non Γ¨ specificato, lo creiamo pulendo quello principale + if not config.get("candles_connector"): + # Rimuove i suffissi per puntare allo Spot (es: binance_perpetual -> binance) + clean_conn = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "") + config["candles_connector"] = clean_conn + + # Se la coppia delle candele non Γ¨ specificata, usa quella di trading + if not config.get("candles_trading_pair"): + config["candles_trading_pair"] = config.get("trading_pair") + # ------------------------------------- + + if is_spot: + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + else: + # Defaults per i Perpetual se non specificati + if not config.get("leverage"): + config["leverage"] = 1 + if config.get("position_mode") not in ["HEDGE", "ONEWAY"]: + config["position_mode"] = "HEDGE" + + # Validazione campi obbligatori + required = ["connector_name", "trading_pair", "total_amount_quote"] + for field in required: + if not config.get(field): + return False, f"Missing required field: {field}" + + return True, None + +def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + max_num = 0 + for cfg in existing_configs: + parts = cfg.get("id", "").split("_", 1) + if parts and parts[0].isdigit(): + max_num = max(max_num, int(parts[0])) + seq = str(max_num + 1).zfill(3) + connector = config.get("connector_name", "unknown").replace("_perpetual", "").replace("_spot", "") + pair = config.get("trading_pair", "UNKNOWN").upper() + return f"{seq}_dman_{connector}_{pair}" diff --git a/handlers/bots/controllers/funding_rate_arb/__init__.py b/handlers/bots/controllers/funding_rate_arb/__init__.py new file mode 100644 index 00000000..1bf9b9ae --- /dev/null +++ b/handlers/bots/controllers/funding_rate_arb/__init__.py @@ -0,0 +1,48 @@ +""" +Funding Rate Arbitrage Controller Module + +Perp↔Perp delta neutral and Spot↔Perp cash-and-carry arbitrage. +""" + +import io +from typing import Any, Dict, List, Optional, Tuple + +from .._base import BaseController, ControllerField +from .chart import generate_chart, generate_preview_chart +from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config + + +class FundingRateArbController(BaseController): + controller_type = "funding_rate_arb" + display_name = "Funding Rate Arbitrage" + description = "Multi-exchange funding rate arbitrage with hourly normalization" + + @classmethod + def get_defaults(cls) -> Dict[str, Any]: + return DEFAULTS.copy() + + @classmethod + def get_fields(cls) -> Dict[str, ControllerField]: + return FIELDS + + @classmethod + def get_field_order(cls) -> List[str]: + return FIELD_ORDER + + @classmethod + def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + 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: + # Funding rate arb doesn't use candlestick charts + # Generate a simple status chart instead + return generate_chart(config, candles_data, current_price) + + @classmethod + def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + return generate_id(config, existing_configs) + + +__all__ = ["FundingRateArbController", "DEFAULTS", "FIELDS", "FIELD_ORDER", "EDITABLE_FIELDS", + "validate_config", "generate_id", "generate_chart", "generate_preview_chart"] \ No newline at end of file diff --git a/handlers/bots/controllers/funding_rate_arb/chart.py b/handlers/bots/controllers/funding_rate_arb/chart.py new file mode 100644 index 00000000..32893577 --- /dev/null +++ b/handlers/bots/controllers/funding_rate_arb/chart.py @@ -0,0 +1,95 @@ +""" +Funding Rate Arbitrage chart generation. + +Simple chart showing funding rate history and net APY. +""" + +import io +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +import numpy as np + + +def generate_chart( + config: Dict[str, Any], + candles_data: List[Dict[str, Any]], + current_price: Optional[float] = None, +) -> io.BytesIO: + """ + Generate a simple chart for funding rate arbitrage. + + Shows: + - Funding rate history for both exchanges (normalized to hourly) + - Net rate (difference) + - Entry/exit thresholds + """ + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), sharex=True) + + conn_a = config.get("connector_pair_a_connector_name", "Exchange A") + conn_b = config.get("connector_pair_b_connector_name", "Exchange B") + pair = config.get("connector_pair_a_trading_pair", "Unknown") + + entry_threshold = config.get("entry_threshold", 0.000025) + exit_threshold = config.get("exit_threshold", 0.000005) + + # Generate sample data for demonstration + # In real implementation, this would use historical funding rate data + times = [datetime.now() - timedelta(hours=x) for x in range(24, 0, -1)] + + # Simulate funding rates (replace with real data) + np.random.seed(42) + rate_a = 0.00002 + np.random.normal(0, 0.000005, 24) + rate_b = 0.00001 + np.random.normal(0, 0.000008, 24) + net_rate = rate_a - rate_b + + # Plot individual rates + ax1.plot(times, rate_a * 100, label=f"{conn_a} (%/h)", linewidth=1.5) + ax1.plot(times, rate_b * 100, label=f"{conn_b} (%/h)", linewidth=1.5) + ax1.axhline(y=entry_threshold * 100, linestyle='--', color='green', alpha=0.7, label=f'Entry ({entry_threshold*100:.4f}%/h)') + ax1.axhline(y=exit_threshold * 100, linestyle='--', color='red', alpha=0.7, label=f'Exit ({exit_threshold*100:.4f}%/h)') + ax1.axhline(y=0, linestyle='-', color='gray', alpha=0.3) + ax1.set_ylabel('Funding Rate (%/h)') + ax1.legend(loc='upper left', fontsize=8) + ax1.grid(True, alpha=0.3) + ax1.set_title(f'Funding Rates - {pair}') + + # Plot net rate + ax2.fill_between(times, 0, net_rate * 100, where=(net_rate > 0), color='green', alpha=0.3, label='Positive (Long A / Short B)') + ax2.fill_between(times, 0, net_rate * 100, where=(net_rate < 0), color='red', alpha=0.3, label='Negative (Short A / Long B)') + ax2.plot(times, net_rate * 100, color='blue', linewidth=2, label='Net Rate') + ax2.axhline(y=entry_threshold * 100, linestyle='--', color='green', alpha=0.7, label=f'Entry') + ax2.axhline(y=-entry_threshold * 100, linestyle='--', color='green', alpha=0.7) + ax2.axhline(y=exit_threshold * 100, linestyle='--', color='red', alpha=0.7, label=f'Exit') + ax2.axhline(y=-exit_threshold * 100, linestyle='--', color='red', alpha=0.7) + ax2.axhline(y=0, linestyle='-', color='gray', alpha=0.5) + ax2.set_ylabel('Net Rate (%/h)') + ax2.set_xlabel('Time') + ax2.legend(loc='upper left', fontsize=8) + ax2.grid(True, alpha=0.3) + ax2.set_title('Net Funding Rate (A - B)') + + # Format x-axis + ax2.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M')) + ax2.xaxis.set_major_locator(mdates.HourLocator(interval=4)) + plt.setp(ax2.xaxis.get_majorticklabels(), rotation=45, ha='right') + + fig.suptitle(f'Funding Rate Arbitrage - {conn_a} ↔ {conn_b}', fontsize=12) + plt.tight_layout() + + buf = io.BytesIO() + plt.savefig(buf, format='png', dpi=100, bbox_inches='tight') + plt.close(fig) + buf.seek(0) + return buf + + +def generate_preview_chart( + config: Dict[str, Any], + candles_data: List[Dict[str, Any]], + current_price: Optional[float] = None, +) -> io.BytesIO: + """Generate preview chart (same as main chart).""" + return generate_chart(config, candles_data, current_price) \ No newline at end of file diff --git a/handlers/bots/controllers/funding_rate_arb/config.py b/handlers/bots/controllers/funding_rate_arb/config.py new file mode 100644 index 00000000..fd2ae7c6 --- /dev/null +++ b/handlers/bots/controllers/funding_rate_arb/config.py @@ -0,0 +1,292 @@ +""" +Funding Rate Arbitrage controller configuration. +""" + +from typing import Any, Dict, List, Optional, Tuple + +from .._base import ControllerField + +# ============================================ +# DEFAULTS values +# ============================================ + +DEFAULTS: Dict[str, Any] = { + "controller_name": "funding_rate_arb", + "controller_type": "generic", + "id": "", + # Exchange A + "connector_pair_a_connector_name": "kucoin_perpetual", + "connector_pair_a_trading_pair": "SOL-USDT", + # Exchange B + "connector_pair_b_connector_name": "hyperliquid_perpetual", + "connector_pair_b_trading_pair": "SOL-USDT", + # Funding intervals (optional) + "funding_interval_a_hours": None, + "funding_interval_b_hours": None, + # Thresholds + "entry_threshold": 0.0002, # se le fees sono 0.1% =0.001 Γ¨ da 1/5 delle fees + "exit_threshold": 0.00003, # 1/5 - 1/10 of entry_threshold + # Capital and risk + "total_amount_quote": 100, + "leverage": 1, + "position_mode": "HEDGE", + "sl_global": 0.03, + "tp_global": 0.05, + "funding_check_interval": 300, + "executor_refresh_time": 60, +} + +# ============================================ +# FIELD DEFINITIONS +# ============================================ + +FIELDS: Dict[str, ControllerField] = { + "id": ControllerField( + name="id", + label="Config ID", + type="str", + required=True, + hint="Auto-generated", + ), + # Exchange A + "connector_pair_a_connector_name": ControllerField( + name="connector_pair_a_connector_name", + label="Exchange A", + type="str", + required=True, + hint="First exchange connector (perp or spot)", + ), + "connector_pair_a_trading_pair": ControllerField( + name="connector_pair_a_trading_pair", + label="Pair A", + type="str", + required=True, + hint="e.g. SOL-USDT", + ), + # Exchange B + "connector_pair_b_connector_name": ControllerField( + name="connector_pair_b_connector_name", + label="Exchange B", + type="str", + required=True, + hint="Second exchange connector", + ), + "connector_pair_b_trading_pair": ControllerField( + name="connector_pair_b_trading_pair", + label="Pair B", + type="str", + required=True, + hint="e.g. SOL-USDT", + ), + # Funding intervals + "funding_interval_a_hours": ControllerField( + name="funding_interval_a_hours", + label="Funding Interval A (hours)", + type="int", + required=False, + hint="Leave empty for auto-detect", + default=None, + ), + "funding_interval_b_hours": ControllerField( + name="funding_interval_b_hours", + label="Funding Interval B (hours)", + type="int", + required=False, + hint="Leave empty for auto-detect", + default=None, + ), + # Thresholds + "entry_threshold": ControllerField( + name="entry_threshold", + label="Entry Threshold (%/h)", + type="float", + required=True, + hint="Minimum net rate to open (e.g. 0.000025 = 0.0025%/h)", + default=0.000025, + ), + "exit_threshold": ControllerField( + name="exit_threshold", + label="Exit Threshold (%/h)", + type="float", + required=True, + hint="Close when net rate below this (e.g. 0.000005 = 0.0005%/h)", + default=0.000005, + ), + # Capital + "total_amount_quote": ControllerField( + name="total_amount_quote", + label="Total Amount (USDT)", + type="float", + required=True, + hint="Total capital, split equally between legs", + default=100, + ), + "leverage": ControllerField( + name="leverage", + label="Leverage", + type="int", + required=True, + hint="1x recommended (no liquidation risk)", + default=1, + ), + "position_mode": ControllerField( + name="position_mode", + label="Position Mode", + type="str", + required=False, + hint="HEDGE or ONEWAY", + default="HEDGE", + ), + # Risk + "sl_global": ControllerField( + name="sl_global", + label="Global Stop Loss", + type="float", + required=False, + hint="Emergency exit at this loss (e.g. 0.03 = 3%)", + default=0.03, + ), + "tp_global": ControllerField( + name="tp_global", + label="Global Take Profit", + type="float", + required=False, + hint="Emergency exit at this profit (e.g. 0.05 = 5%)", + default=0.05, + ), + # Intervals + "funding_check_interval": ControllerField( + name="funding_check_interval", + label="Check Interval (s)", + type="int", + required=False, + hint="Seconds between funding rate checks", + default=300, + ), + "executor_refresh_time": ControllerField( + name="executor_refresh_time", + label="Refresh Time (s)", + type="int", + required=False, + hint="Cancel unfilled orders after this many seconds", + default=60, + ), +} + + +FIELD_ORDER: List[str] = [ + "id", + "connector_pair_a_connector_name", + "connector_pair_a_trading_pair", + "connector_pair_b_connector_name", + "connector_pair_b_trading_pair", + "funding_interval_a_hours", + "funding_interval_b_hours", + "entry_threshold", + "exit_threshold", + "total_amount_quote", + "leverage", + "position_mode", + "sl_global", + "tp_global", + "funding_check_interval", + "executor_refresh_time", +] + + +EDITABLE_FIELDS: List[str] = [ + "connector_pair_a_connector_name", + "connector_pair_a_trading_pair", + "connector_pair_b_connector_name", + "connector_pair_b_trading_pair", + "funding_interval_a_hours", + "funding_interval_b_hours", + "entry_threshold", + "exit_threshold", + "total_amount_quote", + "leverage", + "sl_global", + "tp_global", + "funding_check_interval", + "executor_refresh_time", +] + +# ============================================ +# HELPER FUNCTIONS +# ============================================ + +def get_flat_fields(config: Dict[str, Any]) -> Dict[str, Any]: + """Estrae i campi in formato piatto per l'editing.""" + ep1 = config.get("connector_pair_a", {}) or {} + ep2 = config.get("connector_pair_b", {}) or {} + flat = {k: v for k, v in config.items() if not isinstance(v, dict)} + flat["connector_pair_a_connector_name"] = ep1.get("connector_name", "") + flat["connector_pair_a_trading_pair"] = ep1.get("trading_pair", "") + flat["connector_pair_b_connector_name"] = ep2.get("connector_name", "") + flat["connector_pair_b_trading_pair"] = ep2.get("trading_pair", "") + flat.pop("connector_pair_a", None) + flat.pop("connector_pair_b", None) + return flat + + +def apply_flat_fields(config: Dict[str, Any], updates: Dict[str, Any]) -> None: + """Applica gli aggiornamenti ai dizionari annidati.""" + for key, value in updates.items(): + if key == "connector_pair_a_connector_name": + config.setdefault("connector_pair_a", {})["connector_name"] = value + elif key == "connector_pair_a_trading_pair": + config.setdefault("connector_pair_a", {})["trading_pair"] = value + elif key == "connector_pair_b_connector_name": + config.setdefault("connector_pair_b", {})["connector_name"] = value + elif key == "connector_pair_b_trading_pair": + config.setdefault("connector_pair_b", {})["trading_pair"] = value + else: + config[key] = value + +def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + """Validate funding rate arb configuration.""" + # Check required fields + required = [ + "connector_pair_a_connector_name", + "connector_pair_a_trading_pair", + "connector_pair_b_connector_name", + "connector_pair_b_trading_pair", + "total_amount_quote", + ] + for field in required: + if not config.get(field): + return False, f"Missing required field: {field}" + + # Validate thresholds + entry = config.get("entry_threshold", 0) + exit_ = config.get("exit_threshold", 0) + if entry <= exit_: + return False, f"Entry threshold ({entry}) must be greater than exit threshold ({exit_})" + + return True, None + + +def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + """Generate unique config ID.""" + 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) + + # Get connector names for ID + conn_a = config.get("connector_pair_a_connector_name", "unknown") + conn_b = config.get("connector_pair_b_connector_name", "unknown") + pair = config.get("connector_pair_a_trading_pair", "UNKNOWN").upper() + + conn_a_clean = conn_a.replace("_perpetual", "").replace("_spot", "") + conn_b_clean = conn_b.replace("_perpetual", "").replace("_spot", "") + + return f"{seq}_fra_{conn_a_clean}_{conn_b_clean}_{pair}" diff --git a/handlers/bots/controllers/lm_multi_pair_dex/__init__.py b/handlers/bots/controllers/lm_multi_pair_dex/__init__.py new file mode 100644 index 00000000..419a99af --- /dev/null +++ b/handlers/bots/controllers/lm_multi_pair_dex/__init__.py @@ -0,0 +1,75 @@ +""" +LMMultiPairDEX Controller Module for Condor. + +Market making multi-coppia ottimizzato per DEX con order book: +- XRPL DEX (latenza 3-5s, fee ~$0.00001) +- Hyperliquid (latenza 0.2ms, maker rebate -0.01%) +""" + +import io +from typing import Any, Dict, List, Optional, Tuple + +from .._base import BaseController, ControllerField +from .chart import generate_chart, generate_preview_chart +from .config import DEFAULTS, FIELD_ORDER, FIELDS, WIZARD_STEPS, generate_id, validate_config +from .analysis import analyze_liquidity, format_liquidity_summary + + +class LMMultiPairDEXController(BaseController): + controller_type = "lm_multi_pair_dex" + display_name = "Liquidity Mining Multi-Pair DEX" + description = "Market making multi-coppia per DEX order book (XRPL, Hyperliquid)" + + @classmethod + def get_defaults(cls) -> Dict[str, Any]: + return DEFAULTS.copy() + + @classmethod + def get_fields(cls) -> Dict[str, ControllerField]: + return FIELDS + + @classmethod + def get_field_order(cls) -> List[str]: + return FIELD_ORDER + + @classmethod + def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + 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: + return generate_chart(config, candles_data, current_price) + + @classmethod + def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + return generate_id(config, existing_configs) + + @classmethod + def analyze_liquidity(cls, config: Dict[str, Any], market_data: Dict[str, Any]) -> Dict[str, Any]: + """Analizza liquiditΓ  per le coppie configurate.""" + return analyze_liquidity(config, market_data) + + @classmethod + def format_analysis(cls, analysis: Dict[str, Any]) -> str: + """Formatta l'analisi per visualizzazione.""" + return format_liquidity_summary(analysis) + + +__all__ = [ + "LMMultiPairDEXController", + "DEFAULTS", + "FIELDS", + "FIELD_ORDER", + "WIZARD_STEPS", + "validate_config", + "generate_id", + "generate_chart", + "generate_preview_chart", + "analyze_liquidity", + "format_liquidity_summary", +] diff --git a/handlers/bots/controllers/lm_multi_pair_dex/analysis.py b/handlers/bots/controllers/lm_multi_pair_dex/analysis.py new file mode 100644 index 00000000..80867870 --- /dev/null +++ b/handlers/bots/controllers/lm_multi_pair_dex/analysis.py @@ -0,0 +1,149 @@ +""" +LMMultiPairDEX analysis utilities for Condor. +""" + +from typing import Any, Dict, List + + +def analyze_liquidity(config: Dict[str, Any], market_data: Dict[str, Any]) -> Dict[str, Any]: + """Analizza liquiditΓ  per la configurazione.""" + connector_name = config.get("connector_name", "unknown") + markets = config.get("markets", []) + + results = {} + total_depth = 0 + liquid_pairs = 0 + + for pair in markets: + pair_data = market_data.get(pair, {}) + metrics = _analyze_pair(connector_name, pair, pair_data) + results[pair] = metrics + + if metrics.get("depth_05_usd", 0) > 10000: + liquid_pairs += 1 + total_depth += metrics.get("depth_05_usd", 0) + + # Stima APY + user_spreads = config.get("sell_spreads", [0.005, 0.01, 0.02]) + avg_spread = sum(user_spreads) / len(user_spreads) if user_spreads else 0.01 + daily_profit = total_depth * 0.1 * avg_spread + apy = (daily_profit / config.get("total_amount_quote", 1000) * 365 * + config.get("portfolio_allocation", 0.1) * 100) + + dex_type = "hyperliquid" if "hyperliquid" in connector_name.lower() else "xrpl" + + return { + "dex_type": dex_type, + "pairs": results, + "total_depth_usd": round(total_depth, 0), + "liquid_pairs": liquid_pairs, + "total_pairs": len(markets), + "estimated_apy": round(min(50, apy), 2), + "warnings": _generate_warnings(results, dex_type), + "recommendations": _generate_recommendations(results, dex_type, config), + "fee_info": _get_fee_info(dex_type) + } + + +def _analyze_pair(connector_name: str, pair: str, data: Dict) -> Dict: + """Analizza una singola coppia.""" + best_bid = data.get("best_bid", 0) + best_ask = data.get("best_ask", 0) + + if not best_bid or not best_ask: + return {"error": "No order book data", "is_liquid": False} + + mid_price = (best_bid + best_ask) / 2 + spread_pct = (best_ask - best_bid) / mid_price * 100 + + depth = data.get("depth_05_usd", 0) + liquidity_score = min(1.0, depth / 50000) + + return { + "pair": pair, + "mid_price": round(mid_price, 8), + "spread_pct": round(spread_pct, 4), + "depth_05_usd": round(depth, 0), + "liquidity_score": round(liquidity_score, 2), + "is_liquid": liquidity_score >= 0.3, + } + + +def _generate_warnings(metrics: Dict, dex_type: str) -> List[str]: + """Genera warning.""" + warnings = [] + for pair, m in metrics.items(): + if "error" in m: + warnings.append(f"⚠️ {pair}: {m['error']}") + elif not m.get("is_liquid", False): + warnings.append(f"⚠️ {pair}: liquiditΓ  bassa ({m.get('depth_05_usd', 0):,.0f} USD)") + elif m.get("spread_pct", 0) > 2: + warnings.append(f"⚠️ {pair}: spread alto ({m.get('spread_pct', 0):.2f}%)") + + if dex_type == "xrpl" and not warnings: + warnings.append("ℹ️ XRPL: fee quasi zero, pazienza necessaria.") + elif dex_type == "hyperliquid" and not warnings: + warnings.append("πŸ’° Hyperliquid: maker rebate -0.01% attivo!") + + return warnings + + +def _generate_recommendations(metrics: Dict, dex_type: str, config: Dict) -> List[str]: + """Genera raccomandazioni.""" + recs = [] + + if dex_type == "xrpl": + if config.get("order_refresh_time", 30) < 60: + recs.append("⏱️ XRPL: aumenta order_refresh_time a 60+ secondi") + if config.get("cooldown_time", 15) < 30: + recs.append("⏸️ XRPL: aumenta cooldown_time a 30 secondi") + elif dex_type == "hyperliquid": + if config.get("order_refresh_time", 45) > 35: + recs.append("⚑ Hyperliquid: riduci order_refresh_time a 30 secondi") + if config.get("token", "") != "USDC": + recs.append("πŸ’‘ Hyperliquid: usa USDC per fee migliori") + + return recs + + +def _get_fee_info(dex_type: str) -> Dict: + """Info fee per DEX.""" + if dex_type == "hyperliquid": + return {"maker_fee": "-0.01%", "note": "Ti PAGANO per fornire liquiditΓ "} + else: + return {"maker_fee": "~0.000012 XRP", "note": "Fee quasi zero"} + + +def format_liquidity_summary(analysis: Dict[str, Any]) -> str: + """Formatta l'analisi.""" + if "error" in analysis: + return f"⚠️ Errore: {analysis['error']}" + + dex_name = "HYPERLIQUID" if analysis.get("dex_type") == "hyperliquid" else "XRPL" + lines = [ + f"πŸ“Š LMMultiPairDEX - {dex_name}", + "", + f"πŸ’° Fee: {analysis.get('fee_info', {}).get('maker_fee', 'N/A')} maker", + f" {analysis.get('fee_info', {}).get('note', '')}", + "", + f"πŸ“ˆ Riepilogo:", + f" LiquiditΓ  totale: ${analysis.get('total_depth_usd', 0):,.0f}", + f" Coppie liquide: {analysis.get('liquid_pairs', 0)}/{analysis.get('total_pairs', 0)}", + f" APY stimata: {analysis.get('estimated_apy', 0)}%", + "" + ] + + for pair, m in analysis.get("pairs", {}).items(): + if "error" in m: + lines.append(f" ❌ {pair}: {m['error']}") + else: + icon = "βœ…" if m.get("is_liquid") else "⚠️" + lines.append(f" {icon} {pair}: spread={m.get('spread_pct', 0):.2f}% | depth=${m.get('depth_05_usd', 0):,.0f}") + + if analysis.get("warnings"): + lines.extend(["", "⚠️ Avvertenze:"] + [f" {w}" for w in analysis["warnings"]]) + + if analysis.get("recommendations"): + lines.extend(["", "πŸ’‘ Raccomandazioni:"] + [f" {r}" for r in analysis["recommendations"]]) + + return "\n".join(lines) diff --git a/handlers/bots/controllers/lm_multi_pair_dex/chart.py b/handlers/bots/controllers/lm_multi_pair_dex/chart.py new file mode 100644 index 00000000..20d8b877 --- /dev/null +++ b/handlers/bots/controllers/lm_multi_pair_dex/chart.py @@ -0,0 +1,148 @@ +""" +LMMultiPairDEX chart generation for Condor. +""" + +import io +import pandas as pd +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +import numpy as np + + +def generate_chart(config: dict, candles_data: list, current_price: float = None) -> io.BytesIO: + """Genera chart per LMMultiPairDEX.""" + if not candles_data or len(candles_data) < 5: + return _generate_simple_chart(config, candles_data) + + connector = config.get("connector_name", "unknown") + markets = config.get("markets", ["XRP-RLUSD"]) + + fig = plt.figure(figsize=(14, 10)) + + # Prezzo + ax1 = plt.subplot(2, 1, 1) + _plot_price(ax1, candles_data, config, markets[0] if markets else "Unknown") + + # Depth e allocazione + ax2 = plt.subplot(2, 1, 2) + _plot_depth_and_allocation(ax2, config, markets[0] if markets else "Unknown") + + dex_name = "HYPERLIQUID" if "hyperliquid" in connector.lower() else "XRPL" + fig.suptitle(f"{dex_name} | LMMultiPairDEX - {', '.join(markets[:3])}", fontsize=12) + + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format='png', dpi=120, bbox_inches='tight') + plt.close(fig) + buf.seek(0) + return buf + + +def _plot_price(ax, candles_data, config, pair): + """Plot prezzo con livelli spread.""" + df = _prepare_dataframe(candles_data) + if df.empty or 'close' not in df.columns: + ax.text(0.5, 0.5, "Waiting for price data...", transform=ax.transAxes, ha='center', va='center') + return + + closes = pd.to_numeric(df['close'], errors='coerce').values + dates = df['datetime'].values + + ax.plot(dates, closes, linewidth=1.5, color='white', label=pair) + + buy_spreads = config.get("buy_spreads", [0.005, 0.01, 0.02]) + sell_spreads = config.get("sell_spreads", [0.005, 0.01, 0.02]) + current_price = closes[-1] if len(closes) > 0 else 0 + + for i, s in enumerate(buy_spreads): + ax.axhline(y=current_price * (1 - s), linestyle='--', alpha=0.5, color='green', linewidth=0.8) + for i, s in enumerate(sell_spreads): + ax.axhline(y=current_price * (1 + s), linestyle='--', alpha=0.5, color='red', linewidth=0.8) + + ax.set_ylabel('Price') + ax.set_title(f'Price with Spread Levels - {pair}') + ax.grid(True, alpha=0.3) + ax.legend(loc='upper left') + + +def _plot_depth_and_allocation(ax, config, pair): + """Plot depth e allocazione.""" + buy_spreads = config.get("buy_spreads", [0.005, 0.01, 0.02]) + sell_spreads = config.get("sell_spreads", [0.005, 0.01, 0.02]) + markets = config.get("markets", []) + + # Depth + amounts = [1000, 2000, 3000] + bid_prices = [-s * 100 for s in buy_spreads] + ask_prices = [s * 100 for s in sell_spreads] + + ax.barh(bid_prices, amounts, color='green', alpha=0.7, label='Buy orders', height=0.3) + ax.barh(ask_prices, amounts, color='red', alpha=0.7, label='Sell orders', height=0.3) + + # Allocazione (testo) + target = config.get("target_base_pct", 0.5) + ax.text(0.02, 0.95, f"Target base: {target*100:.0f}%", transform=ax.transAxes, fontsize=9, verticalalignment='top') + ax.text(0.02, 0.88, f"Coppie: {len(markets)}", transform=ax.transAxes, fontsize=9, verticalalignment='top') + + ax.axhline(y=0, linestyle='-', color='white', alpha=0.5, linewidth=1) + ax.set_xlabel('Amount (USD)') + ax.set_ylabel('Spread from mid (%)') + ax.set_title(f'Order Book Depth - {pair}') + ax.legend(loc='upper right') + ax.grid(True, alpha=0.3, axis='x') + + +def _prepare_dataframe(candles: list) -> pd.DataFrame: + """Prepara DataFrame dalle candele.""" + if not candles: + return pd.DataFrame() + + df = pd.DataFrame(candles) + ts_col = next((c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), None) + + if ts_col: + sample = df[ts_col].iloc[0] + if isinstance(sample, (int, float)): + if sample > 10**12: + df['datetime'] = pd.to_datetime(df[ts_col], unit='ns') + elif sample > 10**10: + df['datetime'] = pd.to_datetime(df[ts_col], unit='ms') + else: + df['datetime'] = pd.to_datetime(df[ts_col], unit='s') + else: + df['datetime'] = pd.to_datetime(df[ts_col]) + else: + df['datetime'] = pd.date_range(end=pd.Timestamp.now(), periods=len(df), freq='5min') + + for col in ['close', 'open', 'high', 'low']: + if col in df.columns: + df[col] = pd.to_numeric(df[col], errors='coerce') + + return df.sort_values('datetime').reset_index(drop=True) + + +def _generate_simple_chart(config: dict, candles_data: list) -> io.BytesIO: + """Chart semplice quando mancano dati.""" + fig, ax = plt.subplots(figsize=(12, 6)) + markets = config.get("markets", ["Unknown"]) + + if not candles_data: + msg = f"Waiting for candle data...\n{', '.join(markets)}" + else: + msg = f"Not enough data ({len(candles_data)} candles)\nNeed at least 5 candles" + + ax.text(0.5, 0.5, msg, transform=ax.transAxes, ha='center', va='center', fontsize=12) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.axis('off') + + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format='png', dpi=100) + plt.close(fig) + buf.seek(0) + return buf + + +def generate_preview_chart(config: dict, candles_data: list, current_price: float = None) -> io.BytesIO: + return generate_chart(config, candles_data, current_price) diff --git a/handlers/bots/controllers/lm_multi_pair_dex/config.py b/handlers/bots/controllers/lm_multi_pair_dex/config.py new file mode 100644 index 00000000..faf70008 --- /dev/null +++ b/handlers/bots/controllers/lm_multi_pair_dex/config.py @@ -0,0 +1,246 @@ +""" +LMMultiPairDEX configuration for Condor. + +Supporta: +- XRPL DEX (latenza 3-5s, fee ~$0.00001) +- Hyperliquid (latenza 0.2ms, maker rebate -0.01%) +""" +from .._base import ControllerField +from decimal import Decimal +from typing import Any, Dict, List, Optional, Tuple + + +# Default configuration values +DEFAULTS: Dict[str, Any] = { + "controller_name": "lm_multi_pair_dex", + "controller_type": "generic", + "connector_name": "xrpl", + "markets": ["XRP-RLUSD"], + "token": "XRP", + "total_amount_quote": 1000, + "portfolio_allocation": 0.10, + "buy_spreads": [0.005, 0.01, 0.02], + "sell_spreads": [0.005, 0.01, 0.02], + "use_dynamic_spreads": True, + "atr_length": 14, + "atr_multiplier_min": 0.5, + "atr_multiplier_max": 2.0, + "order_refresh_time": 45, + "cooldown_time": 20, + "order_refresh_tolerance_pct": 0.01, + "target_base_pct": 0.5, + "min_base_pct": 0.3, + "max_base_pct": 0.7, + "max_skew": 0.2, + "leverage": 1, + "take_profit": None, + "max_spread_multiplier": 3.0, + "min_spread_multiplier": 0.3, + "min_volume_usd": 10000, + "min_liquidity_score": 0.3, +} + +# Field definitions for the Condor wizard +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="DEX connector", + type="str", + required=True, + hint="xrpl or hyperliquid", + ), + "markets": ControllerField( + name="markets", + label="Trading pairs", + type="list", + required=True, + hint="Comma-separated: XRP-RLUSD, BTC-XRP (XRPL) or SOL-USDC, ETH-USDC (Hyperliquid)", + ), + "token": ControllerField( + name="token", + label="Unified token", + type="str", + required=True, + hint="XRPL: XRP or RLUSD | Hyperliquid: USDC", + ), + "total_amount_quote": ControllerField( + name="total_amount_quote", + label="Total capital", + type="float", + required=True, + hint="Total amount in unified token", + default=1000, + ), + "portfolio_allocation": ControllerField( + name="portfolio_allocation", + label="Portfolio allocation", + type="float", + required=False, + hint="Percent of total capital to use (0.1 = 10%)", + default=0.10, + ), + "buy_spreads": ControllerField( + name="buy_spreads", + label="Buy spreads", + type="list", + required=False, + hint="Spreads for buy orders (e.g., 0.005,0.01 = 0.5%,1.0%)", + default=[0.005, 0.01, 0.02], + ), + "sell_spreads": ControllerField( + name="sell_spreads", + label="Sell spreads", + type="list", + required=False, + hint="Spreads for sell orders", + default=[0.005, 0.01, 0.02], + ), + "use_dynamic_spreads": ControllerField( + name="use_dynamic_spreads", + label="Dynamic spreads", + type="bool", + required=False, + hint="Adjust spreads based on ATR volatility", + default=True, + ), + "order_refresh_time": ControllerField( + name="order_refresh_time", + label="Refresh time (s)", + type="int", + required=False, + hint="Cancel and replace unfilled orders after N seconds", + default=45, + ), + "cooldown_time": ControllerField( + name="cooldown_time", + label="Cooldown after fill (s)", + type="int", + required=False, + hint="Wait N seconds after a fill before placing new orders", + default=20, + ), + "order_refresh_tolerance_pct": ControllerField( + name="order_refresh_tolerance_pct", + label="Price tolerance", + type="float", + required=False, + hint="Refresh only if price changed more than this (0.01 = 1%)", + default=0.01, + ), + "target_base_pct": ControllerField( + name="target_base_pct", + label="Target base %", + type="float", + required=False, + hint="Target percentage of base assets (0.5 = 50%)", + default=0.5, + ), + "min_base_pct": ControllerField( + name="min_base_pct", + label="Min base %", + type="float", + required=False, + hint="Below this, buy aggressively", + default=0.3, + ), + "max_base_pct": ControllerField( + name="max_base_pct", + label="Max base %", + type="float", + required=False, + hint="Above this, sell aggressively", + default=0.7, + ), + "max_skew": ControllerField( + name="max_skew", + label="Max skew", + type="float", + required=False, + hint="Minimum order size multiplier (0.2 = 20% of normal)", + default=0.2, + ), + "min_liquidity_score": ControllerField( + name="min_liquidity_score", + label="Min liquidity score", + type="float", + required=False, + hint="Skip pairs with liquidity score below this", + default=0.3, + ), +} + +# Field order in the wizard +FIELD_ORDER: List[str] = [ + "id", + "connector_name", + "markets", + "token", + "total_amount_quote", + "portfolio_allocation", + "buy_spreads", + "sell_spreads", + "use_dynamic_spreads", + "order_refresh_time", + "cooldown_time", + "order_refresh_tolerance_pct", + "target_base_pct", + "min_base_pct", + "max_base_pct", + "max_skew", + "min_liquidity_score", +] + +# Wizard steps – minimal required for quick setup +WIZARD_STEPS: List[str] = [ + "connector_name", + "markets", + "token", + "total_amount_quote", + "portfolio_allocation", + "review", +] + + +def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + """Validate the configuration.""" + required = ["connector_name", "markets", "token"] + for field in required: + if not config.get(field): + return False, f"Missing required field: {field}" + + total = config.get("total_amount_quote", 0) + if total <= 0: + return False, "total_amount_quote must be > 0" + + alloc = config.get("portfolio_allocation", 0) + if alloc <= 0 or alloc > 1: + return False, "portfolio_allocation must be between 0 and 1" + + connector = config.get("connector_name", "") + if connector not in ["xrpl", "hyperliquid"]: + return False, f"connector_name must be 'xrpl' or 'hyperliquid', got '{connector}'" + + return True, None + + +def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + """Generate unique config ID with sequence number.""" + max_num = 0 + for cfg in existing_configs: + cid = cfg.get("id", "") + if cid and cid.split("_")[0].isdigit(): + num = int(cid.split("_")[0]) + max_num = max(max_num, num) + next_num = max_num + 1 + seq = str(next_num).zfill(3) + + connector = config.get("connector_name", "unknown") + first_market = config.get("markets", ["UNKNOWN"])[0].split("-")[0] + return f"{seq}_lmmulti_{connector}_{first_market}" diff --git a/handlers/bots/controllers/macd_bb_v1/__init__.py b/handlers/bots/controllers/macd_bb_v1/__init__.py new file mode 100644 index 00000000..5be59d2e --- /dev/null +++ b/handlers/bots/controllers/macd_bb_v1/__init__.py @@ -0,0 +1,42 @@ +"""MACD BB V1 Controller Module - Directional trading with Bollinger Bands + MACD.""" + +import io +from typing import Any, Dict, List, Optional, Tuple + +from .._base import BaseController, ControllerField +from .chart import generate_chart, generate_preview_chart +from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config + + +class MacdBbV1Controller(BaseController): + controller_type = "macd_bb_v1" + display_name = "MACD BB V1" + description = "Directional trading with Bollinger Bands + MACD confirmation" + + @classmethod + def get_defaults(cls) -> Dict[str, Any]: + return DEFAULTS.copy() + + @classmethod + def get_fields(cls) -> Dict[str, ControllerField]: + return FIELDS + + @classmethod + def get_field_order(cls) -> List[str]: + return FIELD_ORDER + + @classmethod + def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + 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: + return generate_chart(config, candles_data, current_price) + + @classmethod + def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + return generate_id(config, existing_configs) + + +__all__ = ["MacdBbV1Controller", "DEFAULTS", "FIELDS", "FIELD_ORDER", "EDITABLE_FIELDS", + "validate_config", "generate_id", "generate_chart", "generate_preview_chart"] diff --git a/handlers/bots/controllers/macd_bb_v1/analysis.py b/handlers/bots/controllers/macd_bb_v1/analysis.py new file mode 100644 index 00000000..4070527c --- /dev/null +++ b/handlers/bots/controllers/macd_bb_v1/analysis.py @@ -0,0 +1,337 @@ +""" +MACD BB V1 analysis utilities. +Aggiornato con supporto Perpetual e suggerimenti strategie. +""" + +import math +from typing import Any, Dict, List, Optional, Tuple + + +def _ema(values: List[float], period: int) -> List[float]: + """Calculate EMA for a list of values.""" + if len(values) < period: + return [] + k = 2.0 / (period + 1) + result = [sum(values[:period]) / period] + for v in values[period:]: + result.append(v * k + result[-1] * (1 - k)) + return result + + +def calculate_bbp_series( + candles: List[Dict[str, Any]], + bb_length: int = 100, + bb_std: float = 2.0, +) -> List[float]: + """Calculate Bollinger Band Percent (BBP) series.""" + closes = [] + for c in candles: + close = c.get("close") or c.get("c") + if close is not None: + closes.append(float(close)) + + if len(closes) < bb_length: + return [] + + bbp_values = [] + for i in range(len(closes)): + if i < bb_length - 1: + continue + window = closes[i - bb_length + 1: i + 1] + sma = sum(window) / bb_length + variance = sum((x - sma) ** 2 for x in window) / bb_length + std = math.sqrt(variance) + upper = sma + bb_std * std + lower = sma - bb_std * std + band_width = upper - lower + bbp_values.append((closes[i] - lower) / band_width if band_width > 0 else 0.5) + + return bbp_values + + +def calculate_macd_series( + candles: List[Dict[str, Any]], + fast: int = 21, + slow: int = 42, + signal: int = 9, +) -> Tuple[List[float], List[float], List[float]]: + """ + Calculate MACD, Signal line, and Histogram. + """ + closes = [float(c.get("close") or c.get("c") or 0) for c in candles] + if len(closes) < slow + signal: + return [], [], [] + + ema_fast = _ema(closes, fast) + ema_slow = _ema(closes, slow) + + offset = slow - fast + ema_fast_aligned = ema_fast[offset:] + macd_line = [f - s for f, s in zip(ema_fast_aligned, ema_slow)] + + signal_line = _ema(macd_line, signal) + macd_aligned = macd_line[signal - 1:] + histogram = [m - s for m, s in zip(macd_aligned, signal_line)] + + return macd_aligned, signal_line, histogram + + +def suggest_bb_thresholds( + bbp_values: List[float], + long_percentile: float = 15.0, + short_percentile: float = 85.0, +) -> Tuple[float, float]: + """Suggest bb_long_threshold and bb_short_threshold from BBP distribution.""" + if not bbp_values: + return 0.0, 1.0 + + sorted_bbp = sorted(bbp_values) + n = len(sorted_bbp) + long_idx = max(0, min(int(n * long_percentile / 100), n - 1)) + short_idx = max(0, min(int(n * short_percentile / 100), n - 1)) + + return round(sorted_bbp[long_idx], 3), round(sorted_bbp[short_idx], 3) + + +def calculate_natr(candles: List[Dict[str, Any]], period: int = 14) -> Optional[float]: + """Calculate Normalized ATR.""" + if not candles or len(candles) < period + 1: + return None + + true_ranges = [] + for i in range(1, len(candles)): + high = float(candles[i].get("high", 0) or 0) + low = float(candles[i].get("low", 0) or 0) + prev_close = float(candles[i - 1].get("close", 0) or 0) + if not all([high, low, prev_close]): + continue + tr = max(high - low, abs(high - prev_close), abs(low - prev_close)) + true_ranges.append(tr) + + if len(true_ranges) < period: + return None + + atr = sum(true_ranges[-period:]) / period + current_close = float(candles[-1].get("close", 0) or 0) + return atr / current_close if current_close > 0 else None + + +def analyze_candles_for_macd_bb( + candles: List[Dict[str, Any]], + bb_length: int = 100, + bb_std: float = 2.0, + macd_fast: int = 21, + macd_slow: int = 42, + macd_signal: int = 9, + natr_period: int = 14, +) -> Dict[str, Any]: + """ + Analisi completa per MACD BB V1. + """ + result = { + "bbp_current": None, + "bb_upper": None, + "bb_middle": None, + "bb_lower": None, + "natr": None, + "macd_current": None, + "macd_histogram_current": None, + "suggested_long_threshold": 0.0, + "suggested_short_threshold": 1.0, + "signal_count_long": 0, + "signal_count_short": 0, + "pct_below_lower": 0.0, + "pct_above_upper": 0.0, + "analysis_candles": len(candles), + } + + if not candles or len(candles) < max(bb_length, macd_slow + macd_signal) + natr_period: + return result + + bbp_values = calculate_bbp_series(candles, bb_length, bb_std) + if not bbp_values: + return result + + result["bbp_current"] = round(bbp_values[-1], 3) + n = len(bbp_values) + result["pct_below_lower"] = round(sum(1 for v in bbp_values if v < 0) / n * 100, 1) + result["pct_above_upper"] = round(sum(1 for v in bbp_values if v > 1) / n * 100, 1) + + closes = [float(c.get("close") or c.get("c") or 0) for c in candles] + if len(closes) >= bb_length: + window = closes[-bb_length:] + sma = sum(window) / bb_length + variance = sum((x - sma) ** 2 for x in window) / bb_length + std = math.sqrt(variance) + result["bb_upper"] = round(sma + bb_std * std, 6) + result["bb_middle"] = round(sma, 6) + result["bb_lower"] = round(sma - bb_std * std, 6) + + macd_line, signal_line, histogram = calculate_macd_series(candles, macd_fast, macd_slow, macd_signal) + if macd_line: result["macd_current"] = round(macd_line[-1], 6) + if histogram: result["macd_histogram_current"] = round(histogram[-1], 6) + + long_thr, short_thr = suggest_bb_thresholds(bbp_values) + result["suggested_long_threshold"] = long_thr + result["suggested_short_threshold"] = short_thr + + if histogram and bbp_values: + h_len = len(histogram) + b_aligned = bbp_values[-h_len:] + m_aligned = macd_line[-h_len:] + result["signal_count_long"] = sum(1 for b, m, h in zip(b_aligned, m_aligned, histogram) if b < long_thr and h > 0 and m < 0) + result["signal_count_short"] = sum(1 for b, m, h in zip(b_aligned, m_aligned, histogram) if b > short_thr and h < 0 and m > 0) + + result["natr"] = calculate_natr(candles, natr_period) + return result + + +def get_macd_bb_strategy_suggestions(natr: float, analysis: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + """ + Ritorna suggerimenti per MACD BB basati sulla volatilitΓ  e analisi statistica. + I valori TP/SL vengono scalati in base alla volatilitΓ  (NATR). + """ + if not natr or natr <= 0: + natr = 0.01 # Default 1% volatility + + # Determina il regime di volatilitΓ  + if natr < 0.005: # <0.5% + vol_regime = "very_low" + vol_mult = 0.7 + elif natr < 0.01: # 0.5-1% + vol_regime = "low" + vol_mult = 1.0 + elif natr < 0.02: # 1-2% + vol_regime = "moderate" + vol_mult = 1.3 + elif natr < 0.03: # 2-3% + vol_regime = "high" + vol_mult = 1.6 + else: # >3% + vol_regime = "very_high" + vol_mult = 2.0 + + l_thr = analysis.get("suggested_long_threshold", 0.15) + s_thr = analysis.get("suggested_short_threshold", 0.85) + + # TP/SL base scalati con volatilitΓ  + base_tp = 0.025 + base_sl = 0.015 + + # ========== AGGIUNGI TRAILING STOP ========== + # Trailing stop base (activation, delta) + base_ts_activation = 0.015 # 1.5% + base_ts_delta = 0.005 # 0.5% + + return { + "scalping": { + "label": "Target: Scalping (Reattivo)", + "bb_length": 21, + "bb_std": 2.0, + "macd_fast": 12, + "macd_slow": 26, + "macd_signal": 9, + "bb_long_threshold": 0.2, + "bb_short_threshold": 0.8, + "take_profit": round(base_tp * vol_mult * 0.6, 4), + "stop_loss": round(base_sl * vol_mult * 0.7, 4), + "trailing_stop_activation": round(base_ts_activation * vol_mult * 0.8, 4), + "trailing_stop_delta": round(base_ts_delta * vol_mult * 0.8, 4), + "volatility_regime": vol_regime + }, + "swing": { + "label": "Target: Swing (Filtro stretto)", + "bb_length": 100, + "bb_std": 2.5, + "macd_fast": 21, + "macd_slow": 42, + "macd_signal": 9, + "bb_long_threshold": l_thr, + "bb_short_threshold": s_thr, + "take_profit": round(base_tp * vol_mult * 1.6, 4), + "stop_loss": round(base_sl * vol_mult * 1.3, 4), + "trailing_stop_activation": round(base_ts_activation * vol_mult * 1.2, 4), + "trailing_stop_delta": round(base_ts_delta * vol_mult * 1.2, 4), + "volatility_regime": vol_regime + }, + "auto": { + "label": "Target: Auto (Analisi Live)", + "bb_length": 50, + "bb_std": 2.0, + "macd_fast": 21, + "macd_slow": 42, + "macd_signal": 9, + "bb_long_threshold": l_thr, + "bb_short_threshold": s_thr, + "take_profit": round(base_tp * vol_mult, 4), + "stop_loss": round(base_sl * vol_mult, 4), + "trailing_stop_activation": round(base_ts_activation * vol_mult, 4), + "trailing_stop_delta": round(base_ts_delta * vol_mult, 4), + "volatility_regime": vol_regime + } + } + +def format_macd_bb_analysis(analysis: Dict[str, Any]) -> str: + """Format analysis results for display in wizard final step.""" + lines = [] + n_candles = analysis.get("analysis_candles", 0) + natr = analysis.get("natr") + bbp = analysis.get("bbp_current") + bb_upper = analysis.get("bb_upper") + bb_lower = analysis.get("bb_lower") + macd = analysis.get("macd_current") + hist = analysis.get("macd_histogram_current") + l_sig = analysis.get("signal_count_long", 0) + s_sig = analysis.get("signal_count_short", 0) + pct_below = analysis.get("pct_below_lower", 0) + pct_above = analysis.get("pct_above_upper", 0) + + lines.append(f"BB+MACD analysis ({n_candles} candles):") + if bb_upper and bb_lower: + lines.append(f" Range: {bb_lower:.4f} - {bb_upper:.4f}") + if bbp is not None: + # Determina posizione BBP + if bbp < 0.2: + pos = "OVERSOLD" + elif bbp > 0.8: + pos = "OVERBOUGHT" + else: + pos = "neutral" + lines.append(f" BBP now: {bbp:.3f} ({pos})") + if macd is not None and hist is not None: + # Determina segnale MACD + if hist > 0 and macd < 0: + macd_signal = "BULLISH" + elif hist < 0 and macd > 0: + macd_signal = "BEARISH" + else: + macd_signal = "neutral" + lines.append(f" MACD: {macd:.6f} | Hist: {hist:.6f} ({macd_signal})") + if natr: + # Valutazione volatilitΓ  + if natr < 0.005: + vol_assessment = "Very Low (<0.5%) β†’ use tighter stops" + elif natr < 0.01: + vol_assessment = "Low (0.5-1%) β†’ standard stops" + elif natr < 0.02: + vol_assessment = "Moderate (1-2%) β†’ adjust stops" + elif natr < 0.03: + vol_assessment = "High (2-3%) β†’ wider stops" + else: + vol_assessment = "Very High (>3%) β†’ use wider stops" + lines.append(f" NATR(14): {natr*100:.3f}% ({vol_assessment})") + + lines.append(f" % below lower band: {pct_below:.1f}%") + lines.append(f" % above upper band: {pct_above:.1f}%") + lines.append(f" Combined signals: LONG={l_sig} SHORT={s_sig}") + lines.append(f" β†’ bb_long_threshold: {analysis['suggested_long_threshold']}") + lines.append(f" β†’ bb_short_threshold: {analysis['suggested_short_threshold']}") + # Aggiungi raccomandazione basata sui segnali + if l_sig > s_sig and l_sig > 0: + lines.append(f" β†’ Bias: LONG ({(l_sig/(l_sig+s_sig)*100):.0f}% signals)") + elif s_sig > l_sig and s_sig > 0: + lines.append(f" β†’ Bias: SHORT ({(s_sig/(l_sig+s_sig)*100):.0f}% signals)") + elif l_sig == 0 and s_sig == 0: + lines.append(" β†’ Bias: NEUTRAL (no signals detected)") + + return "\n".join(lines) diff --git a/handlers/bots/controllers/macd_bb_v1/chart.py b/handlers/bots/controllers/macd_bb_v1/chart.py new file mode 100644 index 00000000..743a8fb9 --- /dev/null +++ b/handlers/bots/controllers/macd_bb_v1/chart.py @@ -0,0 +1,328 @@ +""" +MACD BB V1 chart generation. + +4 panels: + 1. Price – candlesticks + BB + MA20/50/EMA9 + 2. Volume – colored bars + 3. BBP – Bollinger Band Percent with long/short thresholds + 4. MACD – histogram + MACD line + signal line + +Signal logic (shown via BBP + MACD panels): + LONG when BBP < bb_long_threshold AND hist > 0 AND macd < 0 + SHORT when BBP > bb_short_threshold AND hist < 0 AND macd > 0 +""" +import io +import time +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +from matplotlib.patches import Rectangle + +def generate_chart(config, candles_data, current_price=None): + if not candles_data or len(candles_data) < 5: + return _generate_simple_chart(candles_data, current_price) + + df = _prepare_dataframe(candles_data) + full_df = df.copy() + MAX_VISIBLE_CANDLES = 96 + for col in ['open', 'high', 'low', 'close', 'volume']: + full_df[col] = pd.to_numeric(full_df.get(col, 0), errors='coerce').fillna(0) + + # ── INDICATORI ────────────────────────────────────────────────── + full_df['ma20'] = full_df['close'].rolling(20).mean() + full_df['ma50'] = full_df['close'].rolling(50).mean() + full_df['ema9'] = full_df['close'].ewm(span=9).mean() + + bb_length = int(config.get('bb_length', 100)) + bb_std_val = float(config.get('bb_std', 2.0)) + rolling = full_df['close'].rolling(bb_length) + full_df['bb_mid'] = rolling.mean() + bb_std_series = rolling.std() + full_df['bb_upper'] = full_df['bb_mid'] + bb_std_val * bb_std_series + full_df['bb_lower'] = full_df['bb_mid'] - bb_std_val * bb_std_series + denom = (full_df['bb_upper'] - full_df['bb_lower']).replace(0, np.nan) + full_df['bbp'] = ((full_df['close'] - full_df['bb_lower']) / denom).clip(-1, 2).fillna(0.5) + # MACD + macd_fast = int(config.get('macd_fast', 21)) + macd_slow = int(config.get('macd_slow', 42)) + macd_signal = int(config.get('macd_signal', 9)) + # MACD SU FULL_DF + ema_fast = full_df['close'].ewm(span=macd_fast, adjust=False).mean() + ema_slow = full_df['close'].ewm(span=macd_slow, adjust=False).mean() + + full_df['macd'] = ema_fast - ema_slow + full_df['macd_signal'] = (full_df['macd'].ewm(span=macd_signal, adjust=False).mean()) + full_df['macd_hist'] = (full_df['macd'] - full_df['macd_signal']) + + # SOLO DOPO TAGLI + df = full_df.tail(MAX_VISIBLE_CANDLES).copy() + + # ── FIGURA ────────────────────────────────────────────────────── + fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1, figsize=(22, 14), sharex=True, gridspec_kw={ + 'height_ratios': [4.5, 1.2, 1.3, 1.5] + } + ) + + fig.patch.set_facecolor('#111111') + for ax in [ax1, ax2, ax3, ax4]: + ax.set_facecolor('#111111') + ax.tick_params(colors='white') + ax.yaxis.label.set_color('white') + ax.spines['bottom'].set_color('#444') + ax.spines['top'].set_color('#444') + ax.spines['left'].set_color('#444') + ax.spines['right'].set_color('#444') + dates = mdates.date2num(df['datetime']) + + if len(dates) > 1: + candle_width = (dates[1] - dates[0]) * 0.85 + volume_width = (dates[1] - dates[0]) * 0.85 + else: + candle_width = volume_width = 0.0005 + + # ── PANNELLO 1: PREZZO ────────────────────────────────────────── + for i in range(len(df)): + o, h, l, c = df.iloc[i][['open', 'high', 'low', 'close']] + color = '#2ecc71' if c >= o else '#e74c3c' + ax1.plot([dates[i], dates[i]], [l, h], color=color, linewidth=1) + ax1.add_patch(Rectangle( + (dates[i] - candle_width / 2, min(o, c)), + candle_width, abs(c - o) or 1e-8, + color=color + )) + + ax1.plot(df['datetime'], df['ma20'], label='MA20', linewidth=1.4, color='#f39c12') + ax1.plot(df['datetime'], df['ma50'], label='MA50', linewidth=1.4, color='#3498db') + ax1.plot(df['datetime'], df['ema9'], label='EMA9', linewidth=1.4, color='#9b59b6') + + ax1.plot(df['datetime'], df['bb_upper'], '--', linewidth=1.1, color='#ffffff', alpha=0.95,label='BB Upper') + ax1.plot(df['datetime'], df['bb_mid'], ':', linewidth=1.1, color='#ffffff', alpha=0.95, label='BB Mid') + ax1.plot(df['datetime'], df['bb_lower'], '--', linewidth=1.1, color='#ffffff', alpha=0.95, label='BB Lower') + ax1.fill_between(df['datetime'], df['bb_lower'], df['bb_upper'], color='#aaaaaa', alpha=0.24) + if current_price: + ax1.axhline(y=current_price, linestyle='--', alpha=0.8, color='gold', linewidth=1.3, label='Price') + + legend1 = ax1.legend(loc='upper left',fontsize=9,ncol=3,framealpha=0) + for text in legend1.get_texts(): + text.set_color('white') + ax1.set_ylabel('Price') + ax1.grid(True, alpha=0.3) + ax1.set_xlim(df['datetime'].min(), df['datetime'].max()) + + # ── PANNELLO 2: VOLUME ────────────────────────────────────────── + vol_colors = [ + '#2ecc71' if df['close'].iloc[i] >= df['open'].iloc[i] else '#e74c3c' + for i in range(len(df)) + ] + ax2.bar(dates, df['volume'], width=volume_width, color=vol_colors, alpha=0.7) + ax2.set_ylabel('Volume') + ax2.grid(True, alpha=0.3) + + # ── PANNELLO 3: BBP ───────────────────────────────────────────── + ax3.plot(df['datetime'], df['bbp'], linewidth=1.5, color='steelblue') + + long_thr = float(config.get('bb_long_threshold', 0.0)) + short_thr = float(config.get('bb_short_threshold', 1.0)) + + ax3.axhline(long_thr, linestyle='--', color='green', alpha=0.8, label=f'Long {long_thr}') + ax3.axhline(short_thr, linestyle='--', color='red', alpha=0.8, label=f'Short {short_thr}') + ax3.axhline(0, linestyle=':', color='gray', alpha=0.5) + ax3.axhline(1, linestyle=':', color='gray', alpha=0.5) + + # evidenzia zone di segnale + ax3.fill_between(df['datetime'], -1, long_thr, alpha=0.07, color='green') + ax3.fill_between(df['datetime'], short_thr, 2, alpha=0.07, color='red') + + legend3 = ax3.legend(loc='upper left', fontsize=9, framealpha=0) + for text in legend3.get_texts(): + text.set_color('white') + ax3.set_ylabel('BBP') + + # ── PANNELLO 4: MACD ──────────────────────────────────────────── + # istogramma colorato: verde se positivo, rosso se negativo + hist_colors = ['#2ecc71' if v >= 0 else '#e74c3c' for v in df['macd_hist']] + ax4.bar(dates, df['macd_hist'], width=volume_width, color=hist_colors, alpha=0.6, label='Hist') + ax4.plot(df['datetime'], df['macd'], linewidth=1.2, color='steelblue', label=f'MACD({macd_fast},{macd_slow})') + ax4.plot(df['datetime'], df['macd_signal'], linewidth=1.0, color='orange', label=f'Signal({macd_signal})') + ax4.axhline(0, linestyle=':', color='gray', alpha=0.5) + + legend4 = ax4.legend(loc='upper left', fontsize=9, framealpha=0) + for text in legend4.get_texts(): + text.set_color('white') + ax4.set_ylabel('MACD') + + # ── FIX ASSE X BASATO SUL TIMEFRAME ─────────────────────────────── + + interval = config.get('interval', '5m') + + if interval == '1m': + locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45]) + formatter = mdates.DateFormatter('%H:%M') + minor_locator = mdates.MinuteLocator(interval=5) + + elif interval == '5m': + locator = mdates.HourLocator(interval=1) + formatter = mdates.DateFormatter('%H:%M') + minor_locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45]) + + elif interval == '15m': + + locator = mdates.HourLocator(interval=3) + formatter = mdates.DateFormatter('%d %b - %H:%M') + minor_locator = mdates.HourLocator(interval=1) + + elif interval == '1h': + + locator = mdates.HourLocator(interval=12) + formatter = mdates.DateFormatter('%d %b - %H:%M') + minor_locator = mdates.HourLocator(interval=3) + + elif interval == '8h': + + locator = mdates.DayLocator(interval=4) + formatter = mdates.DateFormatter('%b%d') + minor_locator = mdates.DayLocator(interval=1) + + else: + + locator = mdates.AutoDateLocator(minticks=6, maxticks=12) + formatter = mdates.ConciseDateFormatter(locator) + minor_locator = None + + # Applica a TUTTI gli assi (non solo ax4) + ax1.tick_params(labelbottom=False) + ax2.tick_params(labelbottom=False) + ax3.tick_params(labelbottom=False) + + for ax in [ax1, ax2, ax3, ax4]: + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(formatter) + plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center') + # grid verticale + ax.grid(True, which='major', axis='x', linestyle='--', alpha=0.15) + + # grid orizzontale + ax.grid( True, which='major', axis='y', alpha=0.25) + + # ── TITOLO ────────────────────────────────────────────────────── + interval = config.get('interval', '5m') + fig.suptitle( + f"{config.get('trading_pair', 'Unknown')} - MACD BB V1 " + f"(BB{bb_length} | MACD{macd_fast}/{macd_slow}/{macd_signal} | {interval})", + fontsize=13 + ) + + plt.tight_layout() + + buf = io.BytesIO() + plt.savefig(buf, format='png', dpi=120, bbox_inches='tight') + plt.close(fig) + buf.seek(0) + return buf + +def _setup_x_axis(ax, df, interval): + """Configura l'asse X in base al timeframe.""" + date_range = df['datetime'].max() - df['datetime'].min() + + # Soglie in secondi + range_seconds = date_range.total_seconds() + + if interval.endswith('m'): + minutes = int(interval[:-1]) + elif interval.endswith('h'): + minutes = int(interval[:-1]) * 60 + elif interval.endswith('d'): + minutes = int(interval[:-1]) * 1440 + else: + minutes = 5 # default + + # Scegli formattatore in base al range totale + if range_seconds < 3600: # meno di 1 ora + # Mostra ore:minuti + locator = mdates.AutoDateLocator(minticks=4, maxticks=8) + formatter = mdates.DateFormatter('%H:%M') + elif range_seconds < 86400: # meno di 1 giorno + # Mostra ore (06:00, 12:00, 18:00) + locator = mdates.HourLocator(interval=6) # ogni 6 ore + formatter = mdates.DateFormatter('%H:%M') + else: + # Mostra date (Mar30, Apr1) + locator = mdates.DayLocator(interval=max(1, int(range_seconds/86400/5))) + formatter = mdates.DateFormatter('%b%d') + + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(formatter) + plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center') +# ── HELPERS ───────────────────────────────────────────────────────── + +def _prepare_dataframe(candles, timezone=None): + if timezone is None: + # Prende il fuso orario del sistema + timezone = time.tzname[0] + df = pd.DataFrame(candles) + + # Cerca colonna timestamp + ts_col = next((c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), None) + + if ts_col: + # Converti timestamp + sample = df[ts_col].iloc[0] + if isinstance(sample, (int, float)): + # Determina se Γ¨ millisecondi o secondi + if sample > 10**12: # nanosecondi + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='ns', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + elif sample > 10**10: # millisecondi (dopo il 1970) + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='ms', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + else: # secondi + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='s', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + else: + df['datetime'] = (pd.to_datetime(df[ts_col], utc=True).dt.tz_convert(timezone).dt.tz_localize(None)) + else: + # Fallback: crea date sequenziali usando l'intervallo dalla config + # NOTA: questo Γ¨ un fallback, idealmente dovresti avere timestamp reali + freq = config.get('interval', '5m') if 'config' in locals() else '5m' + df['datetime'] = pd.date_range( + end=pd.Timestamp.now(), + periods=len(df), + freq=freq + ) + + return df.sort_values('datetime').reset_index(drop=True) + +def _generate_simple_chart(candles_data, current_price): + if not candles_data: + return io.BytesIO() + df = _prepare_dataframe(candles_data) + MAX_VISIBLE_CANDLES = 96 + if len(df) > MAX_VISIBLE_CANDLES: + df = df.tail(MAX_VISIBLE_CANDLES).reset_index(drop=True) + fig, ax = plt.subplots(figsize=(10, 6)) + ax.plot(df['datetime'], pd.to_numeric(df.get('close', pd.Series()), errors='coerce')) + if current_price: + ax.axhline(y=current_price, linestyle='--') + locator = mdates.AutoDateLocator(minticks=6, maxticks=12) + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator)) + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format='png') + plt.close(fig) + buf.seek(0) + return buf + + +def generate_preview_chart(config, candles_data, current_price=None): + return generate_chart(config, candles_data, current_price) diff --git a/handlers/bots/controllers/macd_bb_v1/config.py b/handlers/bots/controllers/macd_bb_v1/config.py new file mode 100644 index 00000000..c7cc475b --- /dev/null +++ b/handlers/bots/controllers/macd_bb_v1/config.py @@ -0,0 +1,363 @@ +""" +MACD BB V1 controller configuration. + +Directional trading strategy combining Bollinger Bands and MACD: +- LONG when BBP < long_threshold AND MACD histogram > 0 AND MACD < 0 +- SHORT when BBP > short_threshold AND MACD histogram < 0 AND MACD > 0 +""" + +from typing import Any, Dict, List, Optional, Tuple + +from .._base import ControllerField + +DEFAULTS: Dict[str, Any] = { + "controller_name": "macd_bb_v1", + "controller_type": "directional_trading", + "id": "", + # Base fields + "candles_config": [], + # Connector + "connector_name": "", + "trading_pair": "", + "total_amount_quote": 1000, + "leverage": 1, + "position_mode": "HEDGE", + # DirectionalTradingControllerConfigBase fields + "max_executors_per_side": 1, + "cooldown_time": 60, + "stop_loss": 0.05, + "take_profit": 0.03, + "take_profit_order_type": 2, + "time_limit": None, + # Trailing stop as object (matches TrailingStop dataclass) + "trailing_stop": { + "activation_price": 0.015, + "trailing_delta": 0.005, + }, + # Candles config + "candles_connector": "", + "candles_trading_pair": "", + "interval": "5m", + # Bollinger Bands + "bb_length": 100, + "bb_std": 2.0, + "bb_long_threshold": 0.0, + "bb_short_threshold": 1.0, + # MACD + "macd_fast": 21, + "macd_slow": 42, + "macd_signal": 9, +} + +FIELDS: Dict[str, ControllerField] = { + "id": ControllerField( + name="id", + label="Config ID", + type="str", + required=True, + hint="Auto-generated" + ), + "connector_name": ControllerField( + name="connector_name", + label="Connector", + type="str", + required=True, + hint="Exchange connector" + ), + "trading_pair": ControllerField( + name="trading_pair", + label="Trading Pair", + type="str", + required=True, + hint="e.g. BTC-USDT" + ), + "leverage": ControllerField( + name="leverage", + label="Leverage", + type="int", + required=True, + hint="e.g. 1, 5, 10", + default=1 + ), + "position_mode": ControllerField( + name="position_mode", + label="Position Mode", + type="str", + required=False, + hint="HEDGE or ONEWAY", + default="HEDGE" + ), + "total_amount_quote": ControllerField( + name="total_amount_quote", + label="Total Amount (Quote)", + type="float", + required=True, + hint="e.g. 1000 USDT" + ), + "max_executors_per_side": ControllerField( + name="max_executors_per_side", + label="Max Executors/Side", + type="int", + required=False, + hint="Max concurrent positions per side (default: 1)", + default=1 + ), + "cooldown_time": ControllerField( + name="cooldown_time", + label="Cooldown Time (s)", + type="int", + required=False, + hint="Seconds between new executors (default: 60)", + default=60 + ), + "stop_loss": ControllerField( + name="stop_loss", + label="Stop Loss", + type="float", + required=False, + hint="Stop loss % (e.g. 0.05 = 5%)", + default=0.05 + ), + "take_profit": ControllerField( + name="take_profit", + label="Take Profit", + type="float", + required=False, + hint="Take profit % (e.g. 0.03 = 3%)", + default=0.03 + ), + "take_profit_order_type": ControllerField( + name="take_profit_order_type", + label="TP Order Type", + type="int", + required=False, + hint="1=Market, 2=Limit, 3=Limit Maker", + default=2 + ), + "time_limit": ControllerField( + name="time_limit", + label="Time Limit (s)", + type="int", + required=False, + hint="Max executor lifetime in seconds (None = no limit)", + default=None + ), + "candles_connector": ControllerField( + name="candles_connector", + label="Candles Connector", + type="str", + required=False, + hint="Leave empty to use same as connector", + default="" + ), + "candles_trading_pair": ControllerField( + name="candles_trading_pair", + label="Candles Pair", + type="str", + required=False, + hint="Leave empty to use same as trading pair", + default="" + ), + "interval": ControllerField( + name="interval", + label="Candle Interval", + type="str", + required=True, + hint="e.g. 1m, 5m, 1h, 8h", + default="5m" + ), + "bb_length": ControllerField( + name="bb_length", + label="BB Length", + type="int", + required=False, + hint="Bollinger Bands period (default: 100)", + default=100 + ), + "bb_std": ControllerField( + name="bb_std", + label="BB Std Dev", + type="float", + required=False, + hint="Standard deviations (default: 2.0)", + default=2.0 + ), + "bb_long_threshold": ControllerField( + name="bb_long_threshold", + label="BB Long Threshold", + type="float", + required=False, + hint="BBP below this β†’ LONG signal (default: 0.0)", + default=0.0 + ), + "bb_short_threshold": ControllerField( + name="bb_short_threshold", + label="BB Short Threshold", + type="float", + required=False, + hint="BBP above this β†’ SHORT signal (default: 1.0)", + default=1.0 + ), + "macd_fast": ControllerField( + name="macd_fast", + label="MACD Fast", + type="int", + required=False, + hint="Fast EMA period (default: 21)", + default=21 + ), + "macd_slow": ControllerField( + name="macd_slow", + label="MACD Slow", + type="int", + required=False, + hint="Slow EMA period (default: 42)", + default=42 + ), + "macd_signal": ControllerField( + name="macd_signal", + label="MACD Signal", + type="int", + required=False, + hint="Signal line period (default: 9)", + default=9 + ), +} + +FIELD_ORDER: List[str] = [ + "bb_length", + "bb_long_threshold", + "bb_short_threshold", + "bb_std", + "candles_connector", + "candles_trading_pair", + "connector_name", + "cooldown_time", + "id", + "interval", + "leverage", + "macd_fast", + "macd_signal", + "macd_slow", + "max_executors_per_side", + "position_mode", + "stop_loss", + "take_profit_order_type", + "take_profit", + "time_limit", + "total_amount_quote", + "trading_pair", +] + +EDITABLE_FIELDS: List[str] = [ + "bb_length", + "bb_long_threshold", + "bb_short_threshold", + "bb_std", + "candles_connector", + "candles_trading_pair", + "connector_name", + "cooldown_time", + "interval", + "leverage", + "macd_fast", + "macd_signal", + "macd_slow", + "max_executors_per_side", + "stop_loss", + "take_profit_order_type", + "take_profit", + "total_amount_quote", + "trading_pair", + "trailing_stop_activation", + "trailing_stop_delta", + +] + +def get_flat_fields(config: Dict[str, Any]) -> Dict[str, Any]: + """Estrae i campi in formato piatto per l'editing, gestendo trailing_stop.""" + trailing = config.get("trailing_stop", {}) + # Copia i campi esistenti (che sono giΓ  piatti) + flat = dict(config) + # Aggiungi i due campi virtuali + flat["trailing_stop_activation"] = trailing.get("activation_price", 0.015) + flat["trailing_stop_delta"] = trailing.get("trailing_delta", 0.005) + # Rimuovi il dizionario originale per non mostrarlo come campo separato + flat.pop("trailing_stop", None) + return flat + + +def apply_flat_fields(config: Dict[str, Any], updates: Dict[str, Any]) -> None: + """Applica gli aggiornamenti, riconvertendo trailing_stop_activation/delta.""" + for key, value in updates.items(): + if key == "trailing_stop_activation": + if "trailing_stop" not in config: + config["trailing_stop"] = {} + config["trailing_stop"]["activation_price"] = value + elif key == "trailing_stop_delta": + if "trailing_stop" not in config: + config["trailing_stop"] = {} + config["trailing_stop"]["trailing_delta"] = value + else: + config[key] = value + +def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + """Validate and auto-fix MACD BB V1 configuration.""" + + # Identifica il connettore principale + connector = config.get("connector_name", "").lower() + is_spot = "spot" in connector or not ("perpetual" in connector or "margin" in connector) + + # Auto-popola candles_connector se vuoto + if not config.get("candles_connector"): + # Rimuove i suffissi per puntare allo Spot (es: binance_perpetual -> binance) + clean_conn = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "") + config["candles_connector"] = clean_conn + + # Auto-popola candles_trading_pair se vuoto + if not config.get("candles_trading_pair"): + config["candles_trading_pair"] = config.get("trading_pair") + + # Gestione spot vs perpetual + if is_spot: + config["leverage"] = 1 + config["position_mode"] = "ONEWAY" + else: + # Defaults per i Perpetual se non specificati + if not config.get("leverage"): + config["leverage"] = 1 + if config.get("position_mode") not in ["HEDGE", "ONEWAY"]: + config["position_mode"] = "HEDGE" + + # Validazione campi obbligatori + required = ["connector_name", "trading_pair", "total_amount_quote"] + for field in required: + if not config.get(field): + return False, f"Missing required field: {field}" + + # Validazione incrociata periodi + bb_length = config.get("bb_length", 100) + macd_slow = config.get("macd_slow", 42) + if bb_length < macd_slow: + return False, f"BB length ({bb_length}) should be >= MACD slow ({macd_slow}) for sufficient data" + + # Validazione thresholds + bb_long = config.get("bb_long_threshold", 0.0) + bb_short = config.get("bb_short_threshold", 1.0) + if bb_long >= bb_short: + return False, f"BB long threshold ({bb_long}) must be less than BB short threshold ({bb_short})" + + return True, None + + +def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + """Generate sequential ID for MACD BB V1 configuration.""" + max_num = 0 + for cfg in existing_configs: + parts = cfg.get("id", "").split("_", 1) + if parts and parts[0].isdigit(): + max_num = max(max_num, int(parts[0])) + seq = str(max_num + 1).zfill(3) + connector = config.get("connector_name", "unknown").replace("_perpetual", "").replace("_spot", "") + pair = config.get("trading_pair", "UNKNOWN").upper() + return f"{seq}_macdbb_{connector}_{pair}" diff --git a/handlers/bots/controllers/multi_grid_strike/__init__.py b/handlers/bots/controllers/multi_grid_strike/__init__.py new file mode 100644 index 00000000..3fb6bd44 --- /dev/null +++ b/handlers/bots/controllers/multi_grid_strike/__init__.py @@ -0,0 +1,129 @@ +""" +Multi Grid Strike Controller Module + +Provides configuration, validation, and visualization for multi grid strike controllers. + +MultiGridStrike is a strategy that runs multiple independent grids on the same +trading pair, each covering a different price range. Each grid: +- Has its own start_price / end_price / limit_price +- Allocates a percentage of total_amount_quote (amount_quote_pct) +- Can be LONG or SHORT independently +- Is activated only when the market price enters its range + +This allows building layered grid strategies (e.g. a tight grid near current price ++ a wider catch grid below/above) with a single bot instance. +""" + +import io +from typing import Any, Dict, List, Optional, Tuple + +from .._base import BaseController, ControllerField +from .chart import generate_chart, generate_preview_chart +from .config import ( + DEFAULTS, + EDITABLE_FIELDS, + FIELD_ORDER, + FIELDS, + GRID_TYPES, + MGS_WIZARD_STEPS, + ORDER_TYPE_LABELS, + ORDER_TYPE_LIMIT, + ORDER_TYPE_LIMIT_MAKER, + ORDER_TYPE_MARKET, + SIDE_LONG, + SIDE_SHORT, + WIZARD_STEPS, + calculate_auto_prices_for_grid, + generate_id, + validate_config, +) +from .grid_analysis import ( + analyze_all_grids, + calculate_natr, + calculate_price_stats, + calculate_optimal_multi_grids, + format_multi_grid_summary, + generate_theoretical_grid, + suggest_multi_grid_params, +) + + +class MultiGridStrikeController(BaseController): + """Multi Grid Strike controller implementation.""" + + controller_type = "multi_grid_strike" + display_name = "Multi Grid Strike" + description = "Multiple independent grids on the same pair" + + @classmethod + def get_defaults(cls) -> Dict[str, Any]: + """Get default configuration values.""" + defaults = DEFAULTS.copy() + if "triple_barrier_config" in defaults: + defaults["triple_barrier_config"] = defaults["triple_barrier_config"].copy() + defaults["grids"] = [] + return defaults + + @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) + + +__all__ = [ + # Controller class + "MultiGridStrikeController", + # Config + "DEFAULTS", + "FIELDS", + "FIELD_ORDER", + "WIZARD_STEPS", + "MGS_WIZARD_STEPS", + "EDITABLE_FIELDS", + "GRID_TYPES", + "SIDE_LONG", + "SIDE_SHORT", + "ORDER_TYPE_MARKET", + "ORDER_TYPE_LIMIT", + "ORDER_TYPE_LIMIT_MAKER", + "ORDER_TYPE_LABELS", + # Functions + "validate_config", + "calculate_auto_prices_for_grid", + "generate_id", + "generate_chart", + "generate_preview_chart", + # Grid analysis + "calculate_natr", + "calculate_price_stats", + "analyze_all_grids", + "generate_theoretical_grid", + "calculate_optimal_multi_grids", +] diff --git a/handlers/bots/controllers/multi_grid_strike/chart.py b/handlers/bots/controllers/multi_grid_strike/chart.py new file mode 100644 index 00000000..d7f90e22 --- /dev/null +++ b/handlers/bots/controllers/multi_grid_strike/chart.py @@ -0,0 +1,123 @@ +""" +Multi Grid Strike chart generation. + +Generates a candlestick chart with multiple grid zones overlaid, +one per enabled grid in the configuration. + +Each grid is shown with: +- A shaded zone between start_price and end_price (unique color per grid) +- Start/End price lines (dashed) +- Limit price line (dotted red) β€” stop loss level +- Current price line (orange) +""" + +import io +from typing import Any, Dict, List, Optional + +from handlers.dex.visualizations import DARK_THEME, generate_candlestick_chart + +from .config import SIDE_LONG + +# Distinct colors for up to 6 grids +GRID_COLORS = [ + "#4A9EFF", # blue + "#50C878", # green + "#FFB347", # orange + "#DA70D6", # orchid + "#FF6B6B", # red + "#87CEEB", # sky blue +] + + +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 all multi-grid zones overlaid. + + Args: + config: MultiGridStrike configuration dict (with 'grids' list) + candles_data: OHLCV candle data + current_price: Current market price + + Returns: + BytesIO containing PNG image + """ + trading_pair = config.get("trading_pair", "Unknown") + grids = config.get("grids", []) + + data = ( + candles_data if isinstance(candles_data, list) else candles_data.get("data", []) + ) + + total_amount = config.get("total_amount_quote", 0) + active_grids = [g for g in grids if g.get("enabled", True)] + title = f"{trading_pair} - Multi Grid Strike ({len(active_grids)} grids | ${total_amount:.0f} total)" + + hlines = [] + hrects = [] + + for i, grid in enumerate(active_grids): + color = GRID_COLORS[i % len(GRID_COLORS)] + grid_id = grid.get("grid_id", f"grid_{i+1}") + side = grid.get("side", SIDE_LONG) + side_str = "L" if side == SIDE_LONG else "S" + start_price = grid.get("start_price") + end_price = grid.get("end_price") + limit_price = grid.get("limit_price") + pct = grid.get("amount_quote_pct", 0) + amount = total_amount * pct + + if start_price: + hlines.append({ + "y": start_price, + "color": color, + "dash": "dash", + "label": f"[{grid_id}] Start: {start_price:,.4f}", + "label_position": "right", + }) + + if end_price: + hlines.append({ + "y": end_price, + "color": color, + "dash": "dash", + "label": f"[{grid_id}] End: {end_price:,.4f} ({side_str} ${amount:.0f})", + "label_position": "right", + }) + + if limit_price: + hlines.append({ + "y": limit_price, + "color": DARK_THEME["down_color"], + "dash": "dot", + "label": f"[{grid_id}] Limit: {limit_price:,.4f}", + "label_position": "left", + }) + + if start_price and end_price: + hrects.append({ + "y0": min(start_price, end_price), + "y1": max(start_price, end_price), + "color": color, + "opacity": 0.07, + }) + + return generate_candlestick_chart( + candles=data, + title=title, + current_price=current_price, + hlines=hlines, + hrects=hrects, + ) + + +def generate_preview_chart( + config: Dict[str, Any], + candles_data: List[Dict[str, Any]], + current_price: Optional[float] = None, +) -> io.BytesIO: + """Alias for generate_chart β€” used during wizard preview.""" + return generate_chart(config, candles_data, current_price) diff --git a/handlers/bots/controllers/multi_grid_strike/config.py b/handlers/bots/controllers/multi_grid_strike/config.py new file mode 100644 index 00000000..6e689d9e --- /dev/null +++ b/handlers/bots/controllers/multi_grid_strike/config.py @@ -0,0 +1,377 @@ +""" +Multi Grid Strike controller configuration. + +Contains defaults, field definitions, and validation for multi grid strike controllers. + +MultiGridStrike supports multiple grids on the same pair, each with its own +price range and capital allocation (expressed as a percentage of total_amount_quote). +""" + +from typing import Any, Dict, List, Optional, Tuple + +from .._base import ControllerField + +# Side value mapping (same as grid_strike) +SIDE_LONG = 1 +SIDE_SHORT = 2 + +# 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": "multi_grid_strike", + "controller_type": "generic", + "id": "", + "connector_name": "", + "trading_pair": "", + "leverage": 20, + "position_mode": "HEDGE", + "total_amount_quote": 1000, + "min_order_amount_quote": 5, + "min_spread_between_orders": 0.001, + "max_open_orders": 2, + "max_orders_per_batch": 1, + "order_frequency": 3, + "activation_bounds": None, + "keep_position": False, + "triple_barrier_config": { + "open_order_type": ORDER_TYPE_LIMIT_MAKER, + "take_profit": 0.001, + "take_profit_order_type": ORDER_TYPE_LIMIT_MAKER, + "stop_loss": None, + "stop_loss_order_type": ORDER_TYPE_MARKET, + "time_limit": None, + "time_limit_order_type": ORDER_TYPE_MARKET, + "trailing_stop": None, + }, + # grids is a list of GridConfig dicts - empty by default, user adds them + "grids": [], + # Fields from ControllerConfigBase + "manual_kill_switch": False, + "candles_config": [], + "initial_positions": [], +} + +# Field definitions for the configuration 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. WLD-USDT, BTC-USDT", + ), + "leverage": ControllerField( + name="leverage", + label="Leverage", + type="int", + required=True, + hint="e.g. 1, 10, 20", + default=20, + ), + "position_mode": ControllerField( + name="position_mode", + label="Position Mode", + type="str", + required=False, + hint="HEDGE (recommended for multi-grid) or ONEWAY", + default="HEDGE", + ), + "total_amount_quote": ControllerField( + name="total_amount_quote", + label="Total Amount (Quote)", + type="float", + required=True, + hint="Total capital in USDT distributed across all grids", + ), + "max_open_orders": ControllerField( + name="max_open_orders", + label="Max Open Orders", + type="int", + required=False, + hint="Max open orders per grid (default: 2)", + default=2, + ), + "max_orders_per_batch": ControllerField( + name="max_orders_per_batch", + label="Max Orders/Batch", + type="int", + required=False, + hint="Default: 1", + default=1, + ), + "min_order_amount_quote": ControllerField( + name="min_order_amount_quote", + label="Min Order Amount", + type="float", + required=False, + hint="Default: 5", + default=5, + ), + "min_spread_between_orders": ControllerField( + name="min_spread_between_orders", + label="Min Spread Between Orders", + type="float", + required=False, + hint="Default: 0.001", + default=0.001, + ), + "order_frequency": ControllerField( + name="order_frequency", + label="Order Frequency (s)", + type="int", + required=False, + hint="Seconds between order placement (default: 3)", + default=3, + ), + "take_profit": ControllerField( + name="take_profit", + label="Take Profit", + type="float", + required=False, + hint="TP per level (default: 0.001 = 0.1%)", + default=0.001, + ), + "keep_position": ControllerField( + name="keep_position", + label="Keep Position", + type="bool", + required=False, + hint="Keep position open after grid completion", + default=False, + ), + "activation_bounds": ControllerField( + name="activation_bounds", + label="Activation Bounds", + type="float", + required=False, + hint="Price distance to activate orders (None = disabled)", + default=None, + ), +} + +# Field display order +FIELD_ORDER: List[str] = [ + "id", + "connector_name", + "trading_pair", + "leverage", + "position_mode", + "total_amount_quote", + "max_open_orders", + "max_orders_per_batch", + "order_frequency", + "min_order_amount_quote", + "min_spread_between_orders", + "take_profit", + "keep_position", + "activation_bounds", +] + +# Wizard steps +WIZARD_STEPS: List[str] = [ + "connector_name", + "trading_pair", + "leverage", + "total_amount_quote", + "take_profit", + "review", +] + +# Editable fields shown in edit view +EDITABLE_FIELDS: List[str] = [ + "connector_name", + "trading_pair", + "total_amount_quote", + "leverage", + "position_mode", + "take_profit", + "min_spread_between_orders", + "min_order_amount_quote", + "max_open_orders", + "activation_bounds", + "keep_position", +] + + +def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + """ + Validate a multi grid strike configuration. + + Checks: + - Required fields are present + - Grids list is valid (if provided) + - Each grid has correct price ordering based on side + - Sum of amount_quote_pct <= 1.0 + + Returns: + Tuple of (is_valid, error_message) + """ + # Check required top-level fields + required = ["connector_name", "trading_pair", "total_amount_quote"] + for field in required: + if not config.get(field): + return False, f"Missing required field: {field}" + + grids = config.get("grids", []) + + if grids: + total_pct = 0.0 + for i, grid in enumerate(grids): + grid_id = grid.get("grid_id", f"grid_{i}") + side = grid.get("side", SIDE_LONG) + start_price = grid.get("start_price", 0) + end_price = grid.get("end_price", 0) + limit_price = grid.get("limit_price", 0) + pct = grid.get("amount_quote_pct", 0) + + if side == SIDE_LONG: + if not (limit_price < start_price < end_price): + return False, ( + f"Grid '{grid_id}': Invalid prices for LONG " + f"(require limit < start < end). " + f"Got: {limit_price} < {start_price} < {end_price}" + ) + else: + if not (start_price < end_price < limit_price): + return False, ( + f"Grid '{grid_id}': Invalid prices for SHORT " + f"(require start < end < limit). " + f"Got: {start_price} < {end_price} < {limit_price}" + ) + + total_pct += pct + + if total_pct > 1.0 + 1e-9: + return False, ( + f"Sum of amount_quote_pct across grids ({total_pct:.2f}) " + f"exceeds 1.0 (100%). Reduce grid allocations." + ) + + return True, None + + +def calculate_auto_prices_for_grid( + current_price: float, + side: int, + base_pct: float = 0.02, + limit_pct: float = 0.03, +) -> Tuple[float, float, float]: + """ + Calculate start, end, and limit prices for a single grid. + + Uses the same 3:1 ratio logic as grid_strike. + + Returns: + Tuple of (start_price, end_price, limit_price) + """ + if side == SIDE_LONG: + start_price = current_price * (1 - base_pct) + end_price = current_price * (1 + base_pct * 3) + limit_price = current_price * (1 - limit_pct) + else: + start_price = current_price * (1 - base_pct * 3) + end_price = current_price * (1 + base_pct) + limit_price = current_price * (1 + limit_pct) + + return (round(start_price, 6), round(end_price, 6), round(limit_price, 6)) + + +def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + """ + Generate a unique config ID with sequential numbering. + + Format: NNN_mgs_connector_pair + Example: 001_mgs_binance_WLD-USDT + + Args: + config: The configuration being created + existing_configs: List of existing configurations + + Returns: + Generated config ID + """ + 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) + + connector = config.get("connector_name", "unknown") + conn_clean = connector.replace("_perpetual", "").replace("_spot", "") + pair = config.get("trading_pair", "UNKNOWN").upper() + + return f"{seq}_mgs_{conn_clean}_{pair}" + + + + + + +# ============================================ +# Multi-Grid Strategy Types (NEW) +# ============================================ +GRID_TYPES = { + "accumulation_distribution": { + "label": "πŸ“ˆ Accumulation + Distribution", + "description": "Buy in low & sell in high range", + "default_grids": 2, + "max_grids": 6, + "min_grids": 2, + }, + "range_trading": { + "label": "πŸ”„ Range Trading", + "description": "Alternating LONG/SHORT grids in a range", + "default_grids": 4, + "max_grids": 20, + "min_grids": 2, + }, + "pyramid": { + "label": "πŸ”Ί Pyramid DCA", + "description": "Gradual weighted accumulation", + "default_grids": 4, + "max_grids": 8, + "min_grids": 2, + }, +} + +# Wizard steps for MultiGrid Strike +MGS_WIZARD_STEPS: List[str] = [ + "connector_name", + "trading_pair", + "grid_type", + "num_grids", + "leverage", # only for perpetual + "total_amount_quote", + "review", +] diff --git a/handlers/bots/controllers/multi_grid_strike/grid_analysis.py b/handlers/bots/controllers/multi_grid_strike/grid_analysis.py new file mode 100644 index 00000000..1b48c1ea --- /dev/null +++ b/handlers/bots/controllers/multi_grid_strike/grid_analysis.py @@ -0,0 +1,579 @@ +""" +Multi Grid Strike analysis utilities. + +Adapts the grid_strike analysis functions for multi-grid configurations. + +Provides: +- NATR calculation (identical to grid_strike) +- Per-grid parameter suggestions based on volatility +- Theoretical grid generation for each grid in the config +- Combined summary across all grids +- Multi-grid generation based on strategy type +""" + +import logging +import math +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +# ============================================ +# Side constants +# ============================================ + +SIDE_LONG = 1 +SIDE_SHORT = 2 + + +def side_str(side: int) -> str: + """Convert side int to string""" + return "LONG" if side == SIDE_LONG else "SHORT" + + +# ============================================ +# NATR & price stats (identical to grid_strike) +# ============================================ + +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 decimal (e.g. 0.025 = 2.5%). + + Args: + candles: List of candle dicts with high, low, close keys + period: ATR period (default 14) + + Returns: + NATR as decimal, or None if insufficient data + """ + if not candles or len(candles) < period + 1: + return None + + 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 + + tr = max(high - low, abs(high - prev_close), abs(low - prev_close)) + true_ranges.append(tr) + + if len(true_ranges) < period: + return None + + atr = sum(true_ranges[-period:]) / period + current_close = candles[-1].get("close", 0) + if current_close <= 0: + return None + + return atr / current_close + + +def calculate_price_stats( + candles: List[Dict[str, Any]], lookback: int = 100 +) -> Dict[str, float]: + """ + Calculate price statistics from candles. + + Returns: + Dict with current_price, high_price, low_price, range_pct, + avg_candle_range, natr_14, natr_50 + """ + 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 + + 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, + } + + +# ============================================ +# Per-grid theoretical generation +# ============================================ + +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 for a single grid, mirroring the + GridExecutor._generate_grid_levels() logic. + + Args: + start_price: Grid start price + end_price: Grid end price + min_spread: Minimum spread between orders (as decimal) + total_amount: Quote amount allocated to this grid + min_order_amount: Minimum order amount in quote + current_price: Current market price + side: 1=LONG, 2=SHORT + trading_rules: Optional trading rules dict for validation + + Returns: + Dict with levels, num_levels, amount_per_level, spread_pct, + warnings, valid, etc. + """ + warnings = [] + + 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, + } + + grid_range = (high_price - low_price) / low_price + grid_range_pct = grid_range * 100 + + # Trading rules + 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 + + min_notional_with_margin = min_notional * 1.05 + + 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) + min_base_amount = math.ceil(min_base_amount / min_base_increment) * min_base_increment + min_quote_amount = min_base_amount * current_price + + min_step_size = max(min_spread, min_price_increment / current_price) + + 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, + } + + 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: + 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 + n_levels = min(n_levels, int(total_amount / quote_amount_per_level)) + + n_levels = max(1, n_levels) + + # Generate price levels (linear distribution) + 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: + levels.append(round((low_price + high_price) / 2, 8)) + step = grid_range + + amount_per_level = total_amount / n_levels if n_levels > 0 else 0 + + 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}%") + + levels_below = [lv for lv in levels if lv < current_price] + levels_above = [lv for lv in levels if lv >= 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, + } + + +# ============================================ +# MULTI-GRID GENERATION FUNCTIONS (NEW) +# ============================================ + +def calculate_optimal_multi_grids( + current_price: float, + natr: float, + total_amount: float, + min_order_amount: float, + num_grids: int = 2, + grid_type: str = "accumulation_distribution" +) -> List[Dict[str, Any]]: + """ + Calculate optimal multi-grid configurations based on NATR. + """ + if not natr or natr <= 0: + natr = 0.02 # 2% default fallback + + total_range_pct = natr * 3 + grids = [] + + if grid_type == "accumulation_distribution": + # πŸ”§ FIX: Supporta num_grids > 2 + if num_grids == 2: + # Comportamento originale: 1 buy + 1 sell + buy_range_pct = total_range_pct * 0.6 + sell_range_pct = total_range_pct * 0.4 + + buy_start = current_price * (1 - buy_range_pct) + buy_end = current_price * (1 - buy_range_pct * 0.2) + buy_limit = buy_start * 0.998 + grids.append({ + "grid_id": "accumulation", + "start_price": round(buy_start, 6), + "end_price": round(buy_end, 6), + "limit_price": round(buy_limit, 6), + "side": SIDE_LONG, + "amount_quote_pct": 0.5, + "enabled": True, + }) + + sell_start = current_price * (1 + sell_range_pct * 0.2) + sell_end = current_price * (1 + sell_range_pct) + sell_limit = sell_end * 1.002 + grids.append({ + "grid_id": "distribution", + "start_price": round(sell_start, 6), + "end_price": round(sell_end, 6), + "limit_price": round(sell_limit, 6), + "side": SIDE_SHORT, + "amount_quote_pct": 0.5, + "enabled": True, + }) + else: + # πŸ”§ NUOVO: Multiplo accumulation/distribution + # Alterna LONG e SHORT per ogni grid + for i in range(num_grids): + is_long = (i % 2 == 0) + if is_long: + # Accumulation (BUY) - range sotto il prezzo + range_pct = total_range_pct * (0.3 + (i / num_grids) * 0.3) + start = current_price * (1 - range_pct) + end = current_price * (1 - range_pct * 0.2) + limit = start * 0.998 + side = SIDE_LONG + else: + # Distribution (SELL) - range sopra il prezzo + range_pct = total_range_pct * (0.2 + (i / num_grids) * 0.3) + start = current_price * (1 + range_pct * 0.2) + end = current_price * (1 + range_pct) + limit = end * 1.002 + side = SIDE_SHORT + + grids.append({ + "grid_id": f"grid_{i+1}", + "start_price": round(start, 6), + "end_price": round(end, 6), + "limit_price": round(limit, 6), + "side": side, + "amount_quote_pct": round(1.0 / num_grids, 4), + "enabled": True, + }) + + elif grid_type == "range_trading": + # OK - giΓ  funzionante + range_low = current_price * (1 - total_range_pct) + range_high = current_price * (1 + total_range_pct) + step = (range_high - range_low) / num_grids + amount_per_grid = 1.0 / num_grids + + for i in range(num_grids): + start = range_low + (step * i) + end = start + step + side = SIDE_LONG if i % 2 == 0 else SIDE_SHORT + + if side == SIDE_LONG: + limit = start * 0.998 + else: + limit = end * 1.002 + + grids.append({ + "grid_id": f"grid_{i+1}", + "start_price": round(start, 6), + "end_price": round(end, 6), + "limit_price": round(limit, 6), + "side": side, + "amount_quote_pct": round(amount_per_grid, 4), + "enabled": True, + }) + + elif grid_type == "pyramid": + # πŸ”§ FIX: Genera dinamicamente in base a num_grids + # Distribuzione esponenziale: piΓΉ vicino al prezzo, piΓΉ allocazione + allocations = [] + levels = [] + + for i in range(num_grids): + # Distanza dal prezzo: piΓΉ vicino per i primi grid + distance = 0.01 * (i + 1) # 1%, 2%, 3%, ... + # Allocazione decrescente: piΓΉ lontano = meno capitale + weight = 1.0 / (i + 1) # 1, 1/2, 1/3, 1/4, ... + levels.append(distance) + allocations.append(weight) + + # Normalizza allocazioni + total_weight = sum(allocations) + allocations = [w / total_weight for w in allocations] + + for i, (dist_pct, alloc) in enumerate(zip(levels, allocations)): + price = current_price * (1 - dist_pct) + start = price * 0.99 + end = price + limit = price * 0.998 + + grids.append({ + "grid_id": f"dca_{i+1}", + "start_price": round(start, 6), + "end_price": round(end, 6), + "limit_price": round(limit, 6), + "side": SIDE_LONG, + "amount_quote_pct": round(alloc, 4), + "enabled": True, + }) + + return grids + +def suggest_multi_grid_params( + current_price: float, + natr: float, + total_amount: float, + min_order_amount: float, + num_grids: int = 2, + grid_type: str = "accumulation_distribution" +) -> Dict[str, Any]: + """ + Suggest parameters for multiple grids with validation. + """ + # πŸ”§ FIX: Usa num_grids (giΓ  passato correttamente) + grids = calculate_optimal_multi_grids( + current_price, natr, total_amount, min_order_amount, + num_grids, grid_type # ← num_grids Γ¨ qui + ) + + # Validate sum of amount_quote_pct = 1.0 + total_pct = sum(g["amount_quote_pct"] for g in grids) + if abs(total_pct - 1.0) > 0.01: + for g in grids: + g["amount_quote_pct"] = round(g["amount_quote_pct"] / total_pct, 4) + + return { + "grids": grids, + "num_grids": len(grids), + "total_pct": sum(g["amount_quote_pct"] for g in grids), + } + +def format_multi_grid_summary( + config: Dict[str, Any], + current_price: float, + natr: Optional[float] = None, + trading_rules: Optional[Dict[str, Any]] = None, +) -> str: + """ + Format a human-readable summary of all grids. + + Example output: + Grid accumulation (LONG, $500, 50%): + 15 levels (↓4 ↑11) @ $33.33/lvl | step: 0.583% + Grid distribution (SHORT, $500, 50%): + 8 levels (↓5 ↑3) @ $37.50/lvl | step: 1.102% + NATR (14): 1.45% | Total grids: 2 | Capital used: 100% + + Args: + config: Full MultiGridStrike config + current_price: Current market price + natr: Optional pre-calculated NATR + trading_rules: Optional trading rules + + Returns: + Formatted summary string + """ + grids = config.get("grids", []) + total_amount = float(config.get("total_amount_quote", 0)) + min_spread = float(config.get("min_spread_between_orders", 0.001)) + min_order_amount = float(config.get("min_order_amount_quote", 5)) + + lines = [] + total_pct = 0.0 + + for grid in grids: + if not grid.get("enabled", True): + continue + + grid_id = grid.get("grid_id", "?") + side = grid.get("side", SIDE_LONG) + side_str = "LONG" if side == SIDE_LONG else "SHORT" + pct = float(grid.get("amount_quote_pct", 0)) + allocated = total_amount * pct + total_pct += pct + + start = float(grid.get("start_price", 0)) + end = float(grid.get("end_price", 0)) + + analysis = generate_theoretical_grid( + start_price=start, + end_price=end, + min_spread=min_spread, + total_amount=allocated, + min_order_amount=min_order_amount, + current_price=current_price, + side=side, + trading_rules=trading_rules, + ) + + header = f"Grid {grid_id} ({side_str}, ${allocated:.0f}, {pct*100:.0f}%):" + lines.append(header) + + if not analysis.get("valid"): + for w in analysis.get("warnings", []): + lines.append(f" ⚠ {w}") + continue + + n = analysis["num_levels"] + below = analysis.get("levels_below_current", 0) + above = analysis.get("levels_above_current", 0) + amt = analysis["amount_per_level"] + spread = analysis.get("spread_pct", 0) + lines.append( + f" {n} levels (↓{below} ↑{above}) @ ${amt:.2f}/lvl | step: {spread:.3f}%" + ) + + if lines: + footer_parts = [] + if natr is not None: + footer_parts.append(f"NATR (14): {natr*100:.2f}%") + footer_parts.append(f"Total grids: {len([g for g in grids if g.get('enabled')])}") + footer_parts.append(f"Capital used: {total_pct*100:.0f}%") + lines.append(" | ".join(footer_parts)) + + return "\n".join(lines) + +# ============================================ +# Multi-grid specific: analyze all grids at once (BACKWARD COMPATIBILITY) +# ============================================ + +def analyze_all_grids( + config: Dict[str, Any], + current_price: float, + trading_rules: Optional[Dict[str, Any]] = None, +) -> List[Dict[str, Any]]: + """ + Run generate_theoretical_grid for every enabled grid in a MultiGridStrike config. + + This function is kept for backward compatibility with existing code. + + Args: + config: Full MultiGridStrike config dict (with 'grids' list) + current_price: Current market price + trading_rules: Optional trading rules + + Returns: + List of dicts, one per grid, each containing the grid_id and + the result of generate_theoretical_grid for that grid. + """ + results = [] + total_amount = float(config.get("total_amount_quote", 0)) + min_spread = float(config.get("min_spread_between_orders", 0.001)) + min_order_amount = float(config.get("min_order_amount_quote", 5)) + + for grid in config.get("grids", []): + if not grid.get("enabled", True): + continue + + grid_id = grid.get("grid_id", "?") + pct = float(grid.get("amount_quote_pct", 0)) + grid_amount = total_amount * pct + + analysis = generate_theoretical_grid( + start_price=float(grid.get("start_price", 0)), + end_price=float(grid.get("end_price", 0)), + min_spread=min_spread, + total_amount=grid_amount, + min_order_amount=min_order_amount, + current_price=current_price, + side=int(grid.get("side", 1)), + trading_rules=trading_rules, + ) + analysis["grid_id"] = grid_id + analysis["amount_allocated"] = round(grid_amount, 2) + analysis["amount_pct"] = pct + results.append(analysis) + + return results + + diff --git a/handlers/bots/controllers/quantum_grid_allocator/__init__.py b/handlers/bots/controllers/quantum_grid_allocator/__init__.py new file mode 100644 index 00000000..fb16f675 --- /dev/null +++ b/handlers/bots/controllers/quantum_grid_allocator/__init__.py @@ -0,0 +1,47 @@ +""" +Quantum Grid Allocator Controller Module + +Portfolio rebalancing with grid trading on multiple assets. +""" + +import io +from typing import Any, Dict, List, Optional, Tuple + +from .._base import BaseController, ControllerField +from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config + + +class QuantumGridAllocatorController(BaseController): + controller_type = "quantum_grid_allocator" + display_name = "Quantum Grid Allocator" + description = "Portfolio rebalancing with grid trading on multiple assets" + + @classmethod + def get_defaults(cls) -> Dict[str, Any]: + return DEFAULTS.copy() + + @classmethod + def get_fields(cls) -> Dict[str, ControllerField]: + return FIELDS + + @classmethod + def get_field_order(cls) -> List[str]: + return FIELD_ORDER + + @classmethod + def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + 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: + from handlers.dex.visualizations import generate_candlestick_chart + return generate_candlestick_chart(candles_data, title=f"Quantum Grid Allocator - Portfolio") + + @classmethod + def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + return generate_id(config, existing_configs) + + +__all__ = ["QuantumGridAllocatorController"] + + diff --git a/handlers/bots/controllers/quantum_grid_allocator/config.py b/handlers/bots/controllers/quantum_grid_allocator/config.py new file mode 100644 index 00000000..102707a7 --- /dev/null +++ b/handlers/bots/controllers/quantum_grid_allocator/config.py @@ -0,0 +1,128 @@ +""" +Quantum Grid Allocator controller configuration. +""" + +from typing import Any, Dict, List, Optional, Tuple + +from .._base import ControllerField + +DEFAULTS: Dict[str, Any] = { + "controller_name": "quantum_grid_allocator", + "controller_type": "generic", + "id": "", + "connector_name": "binance", + "leverage": 1, + "position_mode": "HEDGE", + "quote_asset": "FDUSD", + "fee_asset": "BNB", + "portfolio_allocation": {"SOL": 0.50}, + "long_only_threshold": 0.2, + "short_only_threshold": 0.2, + "hedge_ratio": 2, + "base_grid_value_pct": 0.08, + "max_grid_value_pct": 0.15, + "grid_range": 0.002, + "tp_sl_ratio": 0.8, + "min_order_amount": 5, + "max_deviation": 0.05, + "max_open_orders": 2, + "safe_extra_spread": 0.0001, + "favorable_order_frequency": 2, + "unfavorable_order_frequency": 5, + "max_orders_per_batch": 1, + "min_spread_between_orders": 0.0001, + "grid_tp_multiplier": 0.0001, + "limit_price_spread": 0.001, + "activation_bounds": 0.0002, + "bb_length": 100, + "bb_std_dev": 2.0, + "interval": "1s", + "dynamic_grid_range": False, + "show_terminated_details": False, +} + +FIELDS: Dict[str, ControllerField] = { + "id": ControllerField(name="id", label="Config ID", type="str", required=True), + "connector_name": ControllerField(name="connector_name", label="Exchange", type="str", required=True), + "leverage": ControllerField(name="leverage", label="Leverage", type="int", required=False, default=1), + "position_mode": ControllerField(name="position_mode", label="Position Mode", type="str", required=False, default="HEDGE"), + "quote_asset": ControllerField(name="quote_asset", label="Quote Asset", type="str", required=False, default="FDUSD"), + "fee_asset": ControllerField(name="fee_asset", label="Fee Asset", type="str", required=False, default="BNB"), + "portfolio_allocation": ControllerField(name="portfolio_allocation", label="Portfolio Allocation", type="str", required=False, hint="e.g. SOL:0.5,BTC:0.3"), + "long_only_threshold": ControllerField(name="long_only_threshold", label="Long Only Threshold", type="float", required=False, default=0.2), + "short_only_threshold": ControllerField(name="short_only_threshold", label="Short Only Threshold", type="float", required=False, default=0.2), + "hedge_ratio": ControllerField(name="hedge_ratio", label="Hedge Ratio", type="float", required=False, default=2), + "base_grid_value_pct": ControllerField(name="base_grid_value_pct", label="Base Grid Value %", type="float", required=False, default=0.08), + "max_grid_value_pct": ControllerField(name="max_grid_value_pct", label="Max Grid Value %", type="float", required=False, default=0.15), + "grid_range": ControllerField(name="grid_range", label="Grid Range", type="float", required=False, default=0.002), + "tp_sl_ratio": ControllerField(name="tp_sl_ratio", label="TP/SL Ratio", type="float", required=False, default=0.8), + "min_order_amount": ControllerField(name="min_order_amount", label="Min Order Amount", type="float", required=False, default=5), + "max_deviation": ControllerField(name="max_deviation", label="Max Deviation", type="float", required=False, default=0.05), + "max_open_orders": ControllerField(name="max_open_orders", label="Max Open Orders", type="int", required=False, default=2), + "safe_extra_spread": ControllerField(name="safe_extra_spread", label="Safe Extra Spread", type="float", required=False, default=0.0001), + "favorable_order_frequency": ControllerField(name="favorable_order_frequency", label="Favorable Order Freq (s)", type="int", required=False, default=2), + "unfavorable_order_frequency": ControllerField(name="unfavorable_order_frequency", label="Unfavorable Order Freq (s)", type="int", required=False, default=5), + "max_orders_per_batch": ControllerField(name="max_orders_per_batch", label="Max Orders/Batch", type="int", required=False, default=1), + "min_spread_between_orders": ControllerField(name="min_spread_between_orders", label="Min Spread", type="float", required=False, default=0.0001), + "grid_tp_multiplier": ControllerField(name="grid_tp_multiplier", label="Grid TP Multiplier", type="float", required=False, default=0.0001), + "limit_price_spread": ControllerField(name="limit_price_spread", label="Limit Price Spread", type="float", required=False, default=0.001), + "activation_bounds": ControllerField(name="activation_bounds", label="Activation Bounds", type="float", required=False, default=0.0002), + "bb_length": ControllerField(name="bb_length", label="BB Length", type="int", required=False, default=100), + "bb_std_dev": ControllerField(name="bb_std_dev", label="BB Std Dev", type="float", required=False, default=2.0), + "interval": ControllerField(name="interval", label="Interval", type="str", required=False, default="1s"), + "dynamic_grid_range": ControllerField(name="dynamic_grid_range", label="Dynamic Grid Range", type="bool", required=False, default=False), + "show_terminated_details": ControllerField(name="show_terminated_details", label="Show Terminated", type="bool", required=False, default=False), +} + +FIELD_ORDER: List[str] = [ + "id", "connector_name", "leverage", "position_mode", "quote_asset", "fee_asset", + "portfolio_allocation", "long_only_threshold", "short_only_threshold", "hedge_ratio", + "base_grid_value_pct", "max_grid_value_pct", "grid_range", "tp_sl_ratio", + "min_order_amount", "max_deviation", "max_open_orders", "safe_extra_spread", + "favorable_order_frequency", "unfavorable_order_frequency", "max_orders_per_batch", + "min_spread_between_orders", "grid_tp_multiplier", "limit_price_spread", + "activation_bounds", "bb_length", "bb_std_dev", "interval", "dynamic_grid_range", + "show_terminated_details", +] + +EDITABLE_FIELDS: List[str] = FIELD_ORDER.copy() + + +def _parse_portfolio_allocation(value: str) -> Dict[str, float]: + """Parse portfolio allocation string like 'SOL:0.5,BTC:0.3'""" + result = {} + for part in value.split(","): + if ":" in part: + asset, pct = part.split(":") + result[asset.strip()] = float(pct.strip()) + return result + + +def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + if not config.get("connector_name"): + return False, "Missing exchange" + + # Validate portfolio allocation + portfolio = config.get("portfolio_allocation", {}) + if isinstance(portfolio, str): + portfolio = _parse_portfolio_allocation(portfolio) + + total = sum(portfolio.values()) + if total >= 1.0: + return False, f"Total allocation {total*100:.0f}% must be less than 100%" + + return True, None + + +def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + max_num = 0 + for cfg in existing_configs: + cfg_id = cfg.get("id", "") + if cfg_id and cfg_id[:3].isdigit(): + max_num = max(max_num, int(cfg_id[:3])) + seq = str(max_num + 1).zfill(3) + connector = config.get("connector_name", "unknown").replace("_perpetual", "").replace("_spot", "") + quote = config.get("quote_asset", "FDUSD") + return f"{seq}_qga_{connector}_{quote}" + + diff --git a/handlers/bots/controllers/stat_arb_v2/__init__.py b/handlers/bots/controllers/stat_arb_v2/__init__.py new file mode 100644 index 00000000..fc938107 --- /dev/null +++ b/handlers/bots/controllers/stat_arb_v2/__init__.py @@ -0,0 +1,60 @@ +""" +Statistical Arbitrage V2 Controller Module. + +Trades two cointegrated assets on the same exchange. +""" + +import io +from typing import Any, Dict, List, Optional, Tuple + +from .._base import BaseController, ControllerField +from .chart import generate_chart, generate_preview_chart +from .config import DEFAULTS, FIELD_ORDER, FIELDS, WIZARD_STEPS, generate_id, validate_config + + +class StatArbV2Controller(BaseController): + controller_type = "stat_arb_v2" + display_name = "Statistical Arbitrage V2" + description = "Trades two cointegrated assets, entering when z-score exceeds threshold" + + @classmethod + def get_defaults(cls) -> Dict[str, Any]: + return DEFAULTS.copy() + + @classmethod + def get_fields(cls) -> Dict[str, ControllerField]: + return FIELDS + + @classmethod + def get_field_order(cls) -> List[str]: + return FIELD_ORDER + + @classmethod + def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + 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: + return generate_chart(config, candles_data, current_price) + + @classmethod + def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + return generate_id(config, existing_configs) + + +__all__ = [ + "StatArbV2Controller", + "DEFAULTS", + "FIELDS", + "FIELD_ORDER", + "WIZARD_STEPS", + "validate_config", + "generate_id", + "generate_chart", + "generate_preview_chart", +] diff --git a/handlers/bots/controllers/stat_arb_v2/analysis.py b/handlers/bots/controllers/stat_arb_v2/analysis.py new file mode 100644 index 00000000..88a14df3 --- /dev/null +++ b/handlers/bots/controllers/stat_arb_v2/analysis.py @@ -0,0 +1,369 @@ +""" +Stat Arb V2 analysis utilities. + +Pure-Python implementation of cointegration analysis +(no sklearn/statsmodels dependency β€” usable directly from Condor/UI layer): + +- Linear regression (beta, alpha, RΒ²) +- ADF test approximation (stationarity) +- Half-life of mean reversion via OU process +- Parameter suggestions +""" + +import math +from typing import Any, Dict, List, Optional, Tuple + + +# --------------------------------------------------------------------------- +# LOW-LEVEL CALCULATIONS +# --------------------------------------------------------------------------- + +def linear_regression(x: List[float], y: List[float]) -> Tuple[float, float, float]: + """ + Calculate linear regression using pure Python (no numpy/sklearn). + + Returns: + (slope, intercept, r_squared) + """ + n = len(x) + if n == 0: + return 0.0, 0.0, 0.0 + + sum_x = sum(x) + sum_y = sum(y) + sum_xy = sum(xi * yi for xi, yi in zip(x, y)) + sum_x2 = sum(xi * xi for xi in x) + sum_y2 = sum(yi * yi for yi in y) + + denominator = n * sum_x2 - sum_x * sum_x + if denominator == 0: + return 0.0, sum_y / n, 0.0 + + slope = (n * sum_xy - sum_x * sum_y) / denominator + intercept = (sum_y - slope * sum_x) / n + + # RΒ² calculation + y_mean = sum_y / n + ss_res = sum((yi - (intercept + slope * xi)) ** 2 for xi, yi in zip(x, y)) + ss_tot = sum((yi - y_mean) ** 2 for yi in y) + + r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0.0 + + return slope, intercept, r_squared + + +def calculate_cumulative_returns(prices: List[float]) -> List[float]: + """Calculate cumulative returns normalized to start at 1.0.""" + if len(prices) < 2: + return [1.0] + + returns = [prices[i] / prices[i - 1] - 1 for i in range(1, len(prices))] + cum_returns = [1.0] + for r in returns: + cum_returns.append(cum_returns[-1] * (1 + r)) + return cum_returns + + +def calculate_adf_approximation(series: List[float]) -> float: + """ + Approximate ADF test p-value using pure Python. + + Returns a p-value (lower = more stationary). + This is a heuristic approximation, not a full ADF implementation. + """ + n = len(series) + if n < 10: + return 0.5 + + # Calculate lagged series + y = [series[i] - series[i - 1] for i in range(1, n)] + x = series[:-1] + + if len(x) < 3: + return 0.5 + + # Regression y ~ x + n_xy = len(x) + sum_x = sum(x) + sum_y = sum(y) + sum_xy = sum(xi * yi for xi, yi in zip(x, y)) + sum_x2 = sum(xi * xi for xi in x) + + denominator = n_xy * sum_x2 - sum_x * sum_x + if denominator == 0: + return 0.5 + + gamma = (n_xy * sum_xy - sum_x * sum_y) / denominator + + # Critical approximation: gamma < 0 indicates mean reversion + if gamma < -0.05: + return 0.01 # Very stationary + elif gamma < -0.02: + return 0.05 # Stationary + elif gamma < -0.01: + return 0.10 # Moderately stationary + elif gamma < 0: + return 0.20 # Weakly stationary + else: + return 0.50 # Non-stationary + + +def calculate_half_life_ou(spread: List[float]) -> Optional[float]: + """ + Calculate half-life of mean reversion using OU process approximation. + """ + if len(spread) < 3: + return None + + spread_lag = spread[:-1] + delta_spread = [spread[i] - spread[i - 1] for i in range(1, len(spread))] + + n = len(spread_lag) + if n < 2: + return None + + # Linear regression: delta = lambda * lag + epsilon + sum_lag = sum(spread_lag) + sum_delta = sum(delta_spread) + sum_lag_delta = sum(l * d for l, d in zip(spread_lag, delta_spread)) + sum_lag2 = sum(l * l for l in spread_lag) + + denominator = n * sum_lag2 - sum_lag * sum_lag + if denominator == 0: + return None + + lambda_ou = (n * sum_lag_delta - sum_lag * sum_delta) / denominator + + if lambda_ou < 0: + return -math.log(2) / lambda_ou + return None + + +def calculate_natr(candles: List[Dict[str, Any]], period: int = 14) -> Optional[float]: + """Calculate Normalized ATR from candles.""" + if not candles or len(candles) < period + 1: + return None + + true_ranges = [] + for i in range(1, len(candles)): + high = float(candles[i].get("high", 0) or 0) + low = float(candles[i].get("low", 0) or 0) + prev_close = float(candles[i - 1].get("close", 0) or 0) + if not all([high, low, prev_close]): + continue + tr = max(high - low, abs(high - prev_close), abs(low - prev_close)) + true_ranges.append(tr) + + if len(true_ranges) < period: + return None + + atr = sum(true_ranges[-period:]) / period + current_close = float(candles[-1].get("close", 0) or 0) + if current_close <= 0: + return None + + return atr / current_close + + +# --------------------------------------------------------------------------- +# COINTEGRATION ANALYSIS +# --------------------------------------------------------------------------- + +def analyze_cointegration( + dominant_prices: List[float], + hedge_prices: List[float], +) -> Dict[str, Any]: + """ + Calculate cointegration metrics between two price series. + + Returns dict with: + - beta: hedge vs dominant slope + - r_squared: regression fit quality (0-1) + - adf_pvalue: stationarity p-value (lower = more stationary) + - half_life: mean reversion half-life in candles + - spread_std: standard deviation of spread (%) + """ + if len(dominant_prices) < 10 or len(hedge_prices) < 10: + return {"error": "Insufficient price data"} + + # Use minimum length for alignment + n = min(len(dominant_prices), len(hedge_prices)) + dom_prices = dominant_prices[-n:] + hedge_prices = hedge_prices[-n:] + + # Calculate cumulative returns + dom_cum = calculate_cumulative_returns(dom_prices) + hedge_cum = calculate_cumulative_returns(hedge_prices) + + # Linear regression + slope, intercept, r_squared = linear_regression(dom_cum, hedge_cum) + + # Spread as % deviation from predicted value + y_pred = [intercept + slope * x for x in dom_cum] + spread_pct = [(hedge_cum[i] - y_pred[i]) / y_pred[i] * 100 for i in range(len(dom_cum))] + + # Remove NaN/inf values + spread_pct = [s for s in spread_pct if math.isfinite(s)] + + if len(spread_pct) < 5: + return {"error": "Invalid spread calculation"} + + # Calculate metrics + spread_std = math.sqrt(sum((s - sum(spread_pct) / len(spread_pct)) ** 2 for s in spread_pct) / len(spread_pct)) + half_life = calculate_half_life_ou(spread_pct) + adf_pvalue = calculate_adf_approximation(spread_pct) + + return { + "beta": round(slope, 4), + "r_squared": round(r_squared, 4), + "adf_pvalue": round(adf_pvalue, 4), + "half_life": round(half_life, 1) if half_life else None, + "spread_std_pct": round(spread_std, 4), + } + + +# --------------------------------------------------------------------------- +# PARAMETER SUGGESTIONS +# --------------------------------------------------------------------------- + +def suggest_entry_threshold(spread_std_pct: Optional[float]) -> float: + """Suggest entry_threshold based on spread volatility.""" + if spread_std_pct is not None: + # Higher volatility = higher threshold + if spread_std_pct > 1.0: + return 2.5 + elif spread_std_pct > 0.5: + return 2.0 + else: + return 1.5 + return 2.0 + + +def suggest_take_profit(spread_std_pct: Optional[float]) -> float: + """Suggest take_profit based on spread volatility.""" + if spread_std_pct is not None: + # Take profit = 30% of typical spread movement + tp = spread_std_pct / 100 * 0.3 + return max(0.0003, round(tp, 6)) + return 0.0008 + + +def suggest_hedge_ratio_range(beta: float) -> Tuple[float, float]: + """Suggest dynamic_hedge_ratio range based on beta.""" + if beta > 0: + central = 1.0 / beta + suggested_min = max(0.2, central * 0.5) + suggested_max = min(3.0, central * 2.0) + else: + suggested_min, suggested_max = 0.5, 2.0 + return suggested_min, suggested_max + + +# --------------------------------------------------------------------------- +# FULL ANALYSIS (for Condor wizard) +# --------------------------------------------------------------------------- + +def analyze_candles_for_stat_arb( + dominant_candles: List[Dict[str, Any]], + hedge_candles: List[Dict[str, Any]], + lookback: int = 300, +) -> Dict[str, Any]: + """ + Full analysis for StatArb parameter suggestions. + + Returns dict with: + - beta, r_squared, adf_pvalue, half_life, spread_std_pct + - suggested_entry_threshold, suggested_take_profit + - suggested_hedge_ratio_range (min, max) + - natr_dominant, natr_hedge + - warnings + """ + result = { + "beta": None, + "r_squared": None, + "adf_pvalue": None, + "half_life": None, + "spread_std_pct": None, + "suggested_entry_threshold": 2.0, + "suggested_take_profit": 0.0008, + "suggested_hedge_ratio_range": [0.5, 2.0], + "natr_dominant": None, + "natr_hedge": None, + "warnings": [], + "analysis_candles": min(len(dominant_candles), len(hedge_candles)), + } + + # Extract close prices + dom_closes = [float(c.get("close") or c.get("c") or 0) for c in dominant_candles if c.get("close")] + hedge_closes = [float(c.get("close") or c.get("c") or 0) for c in hedge_candles if c.get("close")] + + if len(dom_closes) < lookback or len(hedge_closes) < lookback: + result["error"] = f"Insufficient data: need {lookback} candles, got dom={len(dom_closes)} hedge={len(hedge_closes)}" + return result + + # Use only last 'lookback' candles + dom_array = dom_closes[-lookback:] + hedge_array = hedge_closes[-lookback:] + + # Cointegration analysis + coint = analyze_cointegration(dom_array, hedge_array) + if "error" in coint: + result["error"] = coint["error"] + return result + + result["beta"] = coint["beta"] + result["r_squared"] = coint["r_squared"] + result["adf_pvalue"] = coint["adf_pvalue"] + result["half_life"] = coint["half_life"] + result["spread_std_pct"] = coint["spread_std_pct"] + + # Parameter suggestions + result["suggested_entry_threshold"] = suggest_entry_threshold(coint["spread_std_pct"]) + result["suggested_take_profit"] = suggest_take_profit(coint["spread_std_pct"]) + + if coint["beta"] is not None: + min_r, max_r = suggest_hedge_ratio_range(coint["beta"]) + result["suggested_hedge_ratio_range"] = [min_r, max_r] + + # NATR for volatility context + result["natr_dominant"] = calculate_natr(dominant_candles, 14) + result["natr_hedge"] = calculate_natr(hedge_candles, 14) + + # Warnings + if coint["r_squared"] is not None and coint["r_squared"] < 0.5: + result["warnings"].append(f"Low RΒ² ({coint['r_squared']:.2f}) – relationship may be weak") + if coint["adf_pvalue"] is not None and coint["adf_pvalue"] > 0.05: + result["warnings"].append(f"Spread not stationary (p={coint['adf_pvalue']:.3f})") + if coint["half_life"] is not None and coint["half_life"] > 100: + result["warnings"].append(f"Long half-life ({coint['half_life']:.0f} candles) – slow reversion") + + return result + + +def format_stat_arb_summary(analysis: Dict[str, Any]) -> str: + """Format analysis results for display in Condor wizard final step.""" + if "error" in analysis: + return f"⚠️ Analysis error: {analysis['error']}" + + lines = [] + lines.append("πŸ“Š Statistical Arbitrage Analysis") + lines.append("") + lines.append(f"Beta (hedge vs dominant): {analysis.get('beta', 'N/A')}") + lines.append(f"RΒ²: {analysis.get('r_squared', 'N/A')}") + lines.append(f"ADF p-value (stationarity): {analysis.get('adf_pvalue', 'N/A')}") + hl = analysis.get('half_life') + lines.append(f"Half-life (candles): {hl if hl else 'N/A'}") + lines.append(f"Spread std (%): {analysis.get('spread_std_pct', 'N/A')}") + lines.append("") + lines.append("πŸ’‘ Suggested parameters:") + lines.append(f" entry_threshold: {analysis.get('suggested_entry_threshold', 2.0)}") + lines.append(f" take_profit: {analysis.get('suggested_take_profit', 0.0008)}") + hr = analysis.get('suggested_hedge_ratio_range') + if hr: + lines.append(f" dynamic_hedge_ratio range: [{hr[0]:.2f}, {hr[1]:.2f}]") + lines.append("") + if analysis.get("warnings"): + lines.append("⚠️ Warnings:") + for w in analysis["warnings"]: + lines.append(f" β€’ {w}") + return "\n".join(lines) diff --git a/handlers/bots/controllers/stat_arb_v2/chart.py b/handlers/bots/controllers/stat_arb_v2/chart.py new file mode 100644 index 00000000..362c0080 --- /dev/null +++ b/handlers/bots/controllers/stat_arb_v2/chart.py @@ -0,0 +1,229 @@ +""" +Statistical Arbitrage V2 chart generation. + +Generates a chart with: +- Normalized price series of both assets +- Spread between the two assets (as percentage) +- Z-score with entry thresholds +""" + +import io +import math + +import pandas as pd +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +import numpy as np + + +def generate_chart( + config: dict, + candles_data: list, + current_price: float = None, +) -> io.BytesIO: + """ + Generate a chart for Statistical Arbitrage V2. + + Shows: + - Normalized price comparison (both assets starting at 1) + - Spread between the two assets (percentage) + - Z-score with entry thresholds + """ + if not candles_data or len(candles_data) < 10: + return _generate_simple_chart(config, candles_data, current_price) + + # Ottieni le coppie + dom_pair = config.get("trading_pair_dominant", "Dominant") + hedge_pair = config.get("trading_pair_hedge", "Hedge") + interval = config.get("interval", "5m") + entry_threshold = config.get("entry_threshold", 2.0) + + # Prepara i dati + df = _prepare_dataframe(candles_data) + + # Verifica se abbiamo i dati di entrambe le coppie + has_dom = 'close_dom' in df.columns or 'close' in df.columns + has_hedge = 'close_hedge' in df.columns + + if has_dom and has_hedge: + # Dati combinati - usa close_dom e close_hedge + dom_closes = pd.to_numeric(df['close_dom'], errors='coerce').fillna(0).values + hedge_closes = pd.to_numeric(df['close_hedge'], errors='coerce').fillna(0).values + elif 'close' in df.columns: + # Solo una coppia - usa simulazione per demo + dom_closes = pd.to_numeric(df['close'], errors='coerce').fillna(0).values + # Crea una serie fittizia per la hedge (sposta leggermente) + hedge_closes = dom_closes * (1 + np.random.randn(len(dom_closes)) * 0.01) + # Applica smoothing + hedge_closes = pd.Series(hedge_closes).rolling(window=5, min_periods=1).mean().values + else: + return _generate_simple_chart(config, candles_data, current_price) + + if len(dom_closes) < 10 or len(hedge_closes) < 10: + return _generate_simple_chart(config, candles_data, current_price) + + # Normalizza i prezzi (partono da 1) + dom_norm = dom_closes / dom_closes[0] if dom_closes[0] > 0 else dom_closes + hedge_norm = hedge_closes / hedge_closes[0] if hedge_closes[0] > 0 else hedge_closes + + # Calcola lo spread percentuale + # spread = (dominant - hedge) / hedge * 100 + spread = (dom_norm - hedge_norm) / hedge_norm * 100 + + # Calcola z-score + mean_spread = np.mean(spread) + std_spread = np.std(spread) + if std_spread > 0: + z_score = (spread - mean_spread) / std_spread + else: + z_score = np.zeros_like(spread) + + dates = df['datetime'].values + + # Crea la figura con 2 pannelli + fig = plt.figure(figsize=(14, 10)) + + # PANNELLO 1: Prezzi normalizzati + ax1 = plt.subplot(2, 1, 1) + + ax1.plot(dates, dom_norm, label=f"{dom_pair} (normalized)", linewidth=1.5, color='cyan') + ax1.plot(dates, hedge_norm, label=f"{hedge_pair} (normalized)", linewidth=1.5, color='orange') + ax1.set_ylabel('Normalized Price') + ax1.set_title(f'Statistical Arbitrage: {dom_pair} vs {hedge_pair}') + ax1.legend(loc='upper left') + ax1.grid(True, alpha=0.3) + ax1.axhline(y=1.0, linestyle='--', alpha=0.5, color='gray') + + # PANNELLO 2: Spread e Z-score + ax2 = plt.subplot(2, 1, 2) + + # Spread come area + ax2.fill_between(dates, 0, spread, alpha=0.3, color='blue', label='Spread %') + ax2.plot(dates, spread, linewidth=1, color='blue', alpha=0.7) + + # Z-score (secondo asse) + ax2_twin = ax2.twinx() + ax2_twin.plot(dates, z_score, linewidth=1.5, color='purple', label='Z-Score') + ax2_twin.axhline(y=entry_threshold, linestyle='--', alpha=0.7, color='red', linewidth=1, label=f'Entry +{entry_threshold}') + ax2_twin.axhline(y=-entry_threshold, linestyle='--', alpha=0.7, color='green', linewidth=1, label=f'Entry -{entry_threshold}') + ax2_twin.axhline(y=0, linestyle='-', alpha=0.5, color='gray', linewidth=0.8) + ax2_twin.set_ylabel('Z-Score', color='purple') + ax2_twin.tick_params(axis='y', labelcolor='purple') + + ax2.set_ylabel('Spread (%)', color='blue') + ax2.tick_params(axis='y', labelcolor='blue') + ax2.set_xlabel('Time') + ax2.grid(True, alpha=0.3) + + # Legenda combinata + lines1, labels1 = ax2.get_legend_handles_labels() + lines2, labels2 = ax2_twin.get_legend_handles_labels() + ax2.legend(lines1 + lines2, labels1 + labels2, loc='upper left', fontsize=9) + # Formatta l'asse x + if len(dates) > 1: + # Converti in Timestamp per il calcolo + start_date = pd.Timestamp(dates[0]) + end_date = pd.Timestamp(dates[-1]) + date_range = end_date - start_date + total_seconds = date_range.total_seconds() + days = date_range.days + else: + total_seconds = 3600 + days = 0 + + if days >= 3: + locator = mdates.DayLocator(interval=max(1, days // 6)) + formatter = mdates.DateFormatter('%b%d') + rotation = 0 + elif total_seconds < 3600 * 2: + locator = mdates.MinuteLocator(interval=15) + formatter = mdates.DateFormatter('%H:%M') + rotation = 45 + else: + locator = mdates.HourLocator(interval=max(1, int(total_seconds / 3600 // 4))) + formatter = mdates.DateFormatter('%H:%M') + rotation = 45 + + for ax in [ax1, ax2]: + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(formatter) + plt.setp(ax.xaxis.get_majorticklabels(), rotation=rotation, ha='right') + + # Titolo con info + fig.suptitle( + f"{dom_pair} vs {hedge_pair} - Spread Analysis (Z-Score threshold: {entry_threshold}) | {interval}", + fontsize=12, + y=0.98 + ) + + plt.tight_layout() + + buf = io.BytesIO() + plt.savefig(buf, format='png', dpi=120, bbox_inches='tight') + plt.close(fig) + buf.seek(0) + return buf + + +def generate_preview_chart(config: dict, candles_data: list, current_price: float = None) -> io.BytesIO: + """Alias for generate_chart.""" + return generate_chart(config, candles_data, current_price) + + +def _prepare_dataframe(candles: list) -> pd.DataFrame: + """Prepara il DataFrame dalle candele.""" + if not candles: + return pd.DataFrame() + + df = pd.DataFrame(candles) + + # Cerca colonna timestamp + ts_col = next((c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), None) + + if ts_col: + sample = df[ts_col].iloc[0] + if isinstance(sample, (int, float)): + if sample > 10**12: + df['datetime'] = pd.to_datetime(df[ts_col], unit='ns') + elif sample > 10**10: + df['datetime'] = pd.to_datetime(df[ts_col], unit='ms') + else: + df['datetime'] = pd.to_datetime(df[ts_col], unit='s') + else: + df['datetime'] = pd.to_datetime(df[ts_col]) + else: + # Crea date sequenziali + df['datetime'] = pd.date_range(end=pd.Timestamp.now(), periods=len(df), freq='5min') + + # Converti colonne numeriche + for col in ['close', 'close_dom', 'close_hedge', 'open', 'open_dom', 'open_hedge', 'high', 'low']: + if col in df.columns: + df[col] = pd.to_numeric(df[col], errors='coerce') + + return df.sort_values('datetime').reset_index(drop=True) + + +def _generate_simple_chart(config: dict, candles_data: list, current_price: float = None) -> io.BytesIO: + """Genera un chart semplice quando non ci sono abbastanza dati.""" + fig, ax = plt.subplots(figsize=(12, 6)) + + dom_pair = config.get("trading_pair_dominant", "Dominant") + hedge_pair = config.get("trading_pair_hedge", "Hedge") + + if not candles_data or len(candles_data) == 0: + ax.text(0.5, 0.5, f"Waiting for candle data...\n{dom_pair} vs {hedge_pair}", + transform=ax.transAxes, ha='center', va='center', fontsize=12) + else: + ax.text(0.5, 0.5, f"Not enough data to generate chart for\n{dom_pair} vs {hedge_pair}\n\nNeed at least 10 candles, got {len(candles_data)}", + transform=ax.transAxes, ha='center', va='center', fontsize=12) + + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.axis('off') + + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format='png', dpi=100) + plt.close(fig) + buf.seek(0) + return buf diff --git a/handlers/bots/controllers/stat_arb_v2/config.py b/handlers/bots/controllers/stat_arb_v2/config.py new file mode 100644 index 00000000..08ce6a45 --- /dev/null +++ b/handlers/bots/controllers/stat_arb_v2/config.py @@ -0,0 +1,335 @@ +""" +StatArb V2 configuration for Condor. +""" + +from decimal import Decimal +from typing import Any, Dict, List, Optional, Tuple + +from .._base import ControllerField + + +# Default configuration values +DEFAULTS: Dict[str, Any] = { + "controller_name": "stat_arb_v2", + "controller_type": "generic", + "connector_name": "", + "trading_pair_dominant": "", + "trading_pair_hedge": "", + "total_amount_quote": 1000, + "leverage": 20, + "position_mode": "HEDGE", + "interval": "5m", + "lookback_period": 100, + "entry_threshold": 2.0, + "take_profit": 0.0008, + "tp_global": 0.01, + "sl_global": 0.05, + "min_amount_quote": 10, + "quoter_spread": 0.0001, + "quoter_cooldown": 30, + "quoter_refresh": 10, + "max_orders_placed_per_side": 2, + "max_orders_filled_per_side": 2, + "max_position_deviation": 0.1, + "use_dynamic_hedge_ratio": True, + "pos_hedge_ratio": 1.0, + "max_dynamic_hedge_ratio": 3.0, + "min_dynamic_hedge_ratio": 0.2, + "min_r_squared": 0.70, + "adf_pvalue_threshold": 0.05, +} + +# Field definitions for the Condor wizard +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="Exchange connector", + type="str", + required=True, + hint="e.g., binance_perpetual, bybit_perpetual, etc.", + ), + "trading_pair_dominant": ControllerField( + name="trading_pair_dominant", + label="Dominant trading pair", + type="str", + required=True, + hint="e.g., SOL-USDT, BTC-USDT", + ), + "trading_pair_hedge": ControllerField( + name="trading_pair_hedge", + label="Hedge trading pair", + type="str", + required=True, + hint="e.g., XRP-USDT, ETH-USDT", + ), + "total_amount_quote": ControllerField( + name="total_amount_quote", + label="Total capital (quote)", + type="float", + required=True, + hint="Total amount in quote currency (USDT, USDC, etc.)", + default=1000, + ), + "leverage": ControllerField( + name="leverage", + label="Leverage", + type="int", + required=True, + hint="Leverage for perpetual futures (1 for spot)", + default=20, + ), + "position_mode": ControllerField( + name="position_mode", + label="Position mode", + type="str", + required=False, + hint="HEDGE or ONEWAY", + default="HEDGE", + ), + "interval": ControllerField( + name="interval", + label="Candle interval", + type="str", + required=False, + hint="e.g., 1m, 5m, 15m", + default="1m", + ), + "lookback_period": ControllerField( + name="lookback_period", + label="Lookback candles", + type="int", + required=False, + hint="Number of candles for regression and z-score", + default=300, + ), + "entry_threshold": ControllerField( + name="entry_threshold", + label="Entry threshold (z-score)", + type="float", + required=False, + hint="Z-score threshold to trigger a trade (2.0 = 95% quantile)", + default=2.0, + ), + "take_profit": ControllerField( + name="take_profit", + label="Take profit per leg", + type="float", + required=False, + hint="Percent profit to close a leg (e.g., 0.0008 = 0.08%)", + default=0.0008, + ), + "tp_global": ControllerField( + name="tp_global", + label="Global take profit", + type="float", + required=False, + hint="Pair PnL% to close everything (e.g., 0.01 = 1%)", + default=0.01, + ), + "sl_global": ControllerField( + name="sl_global", + label="Global stop loss", + type="float", + required=False, + hint="Pair PnL% loss to close everything (e.g., 0.05 = 5%)", + default=0.05, + ), + "min_amount_quote": ControllerField( + name="min_amount_quote", + label="Min order amount (quote)", + type="float", + required=False, + hint="Minimum notional per order in quote currency", + default=10, + ), + "quoter_spread": ControllerField( + name="quoter_spread", + label="Quoter spread", + type="float", + required=False, + hint="Offset from mid price for limit orders (e.g., 0.0001 = 0.01%)", + default=0.0001, + ), + "quoter_cooldown": ControllerField( + name="quoter_cooldown", + label="Cooldown after fill (s)", + type="int", + required=False, + hint="Seconds to wait before removing a filled executor", + default=30, + ), + "quoter_refresh": ControllerField( + name="quoter_refresh", + label="Refresh time for unfilled (s)", + type="int", + required=False, + hint="Seconds before cancelling and re-pricing an unfilled order", + default=10, + ), + "max_orders_placed_per_side": ControllerField( + name="max_orders_placed_per_side", + label="Max pending orders per side", + type="int", + required=False, + hint="Maximum number of unfilled orders per leg", + default=2, + ), + "max_orders_filled_per_side": ControllerField( + name="max_orders_filled_per_side", + label="Max filled orders per side", + type="int", + required=False, + hint="Maximum number of filled (active) positions per leg", + default=2, + ), + "max_position_deviation": ControllerField( + name="max_position_deviation", + label="Max position deviation", + type="float", + required=False, + hint="Imbalance threshold that blocks one leg (0.1 = 10%)", + default=0.1, + ), + "use_dynamic_hedge_ratio": ControllerField( + name="use_dynamic_hedge_ratio", + label="Use dynamic hedge ratio", + type="bool", + required=False, + hint="Size hedge leg according to OLS beta", + default=True, + ), + "pos_hedge_ratio": ControllerField( + name="pos_hedge_ratio", + label="Fixed hedge ratio (if dynamic off)", + type="float", + required=False, + hint="Hedge notional / dominant notional", + default=1.0, + ), + "max_dynamic_hedge_ratio": ControllerField( + name="max_dynamic_hedge_ratio", + label="Max dynamic ratio", + type="float", + required=False, + hint="Cap for 1/beta", + default=3.0, + ), + "min_dynamic_hedge_ratio": ControllerField( + name="min_dynamic_hedge_ratio", + label="Min dynamic ratio", + type="float", + required=False, + hint="Floor for 1/beta", + default=0.2, + ), + "min_r_squared": ControllerField( + name="min_r_squared", + label="Min RΒ² to trade", + type="float", + required=False, + hint="Minimum coefficient of determination to allow signals", + default=0.70, + ), + "adf_pvalue_threshold": ControllerField( + name="adf_pvalue_threshold", + label="ADF p-value threshold", + type="float", + required=False, + hint="Maximum p-value for stationarity (lower is better)", + default=0.05, + ), +} + +# Field order in the wizard +FIELD_ORDER: List[str] = [ + "id", + "connector_name", + "trading_pair_dominant", + "trading_pair_hedge", + "total_amount_quote", + "leverage", + "position_mode", + "interval", + "lookback_period", + "entry_threshold", + "take_profit", + "tp_global", + "sl_global", + "min_amount_quote", + "quoter_spread", + "quoter_cooldown", + "quoter_refresh", + "max_orders_placed_per_side", + "max_orders_filled_per_side", + "max_position_deviation", + "use_dynamic_hedge_ratio", + "pos_hedge_ratio", + "max_dynamic_hedge_ratio", + "min_dynamic_hedge_ratio", + "min_r_squared", + "adf_pvalue_threshold", +] + +# Wizard steps – minimal required for quick setup +WIZARD_STEPS: List[str] = [ + "connector_name", + "trading_pair_dominant", + "trading_pair_hedge", + "total_amount_quote", + "leverage", + "position_mode", + "entry_threshold", + "take_profit", + "tp_global", + "review", +] + + +def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + """Validate the configuration.""" + # Check required fields + required = ["connector_name", "trading_pair_dominant", "trading_pair_hedge"] + for field in required: + if not config.get(field): + return False, f"Missing required field: {field}" + + # Check that total_amount_quote > 0 + total = config.get("total_amount_quote", 0) + if total <= 0: + return False, "total_amount_quote must be > 0" + + # Check entry_threshold positive + entry = config.get("entry_threshold", 0) + if entry <= 0: + return False, "entry_threshold must be positive" + + # Check take_profit positive + tp = config.get("take_profit", 0) + if tp <= 0: + return False, "take_profit must be positive" + + return True, None + + +def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + """Generate unique config ID with sequence number.""" + max_num = 0 + for cfg in existing_configs: + cid = cfg.get("id", "") + if cid and cid.split("_")[0].isdigit(): + num = int(cid.split("_")[0]) + max_num = max(max_num, num) + next_num = max_num + 1 + seq = str(next_num).zfill(3) + + connector = config.get("connector_name", "unknown").replace("_perpetual", "").replace("_spot", "") + dom = config.get("trading_pair_dominant", "UNKNOWN").split("-")[0] + hedge = config.get("trading_pair_hedge", "UNKNOWN").split("-")[0] + return f"{seq}_statarb_{connector}_{dom}_{hedge}" diff --git a/handlers/bots/controllers/supertrend_v1/__init__.py b/handlers/bots/controllers/supertrend_v1/__init__.py new file mode 100644 index 00000000..7f9a8722 --- /dev/null +++ b/handlers/bots/controllers/supertrend_v1/__init__.py @@ -0,0 +1,42 @@ +"""SuperTrend V1 Controller Module - Directional trading with SuperTrend indicator.""" + +import io +from typing import Any, Dict, List, Optional, Tuple + +from .._base import BaseController, ControllerField +from .chart import generate_chart, generate_preview_chart +from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config + + +class SuperTrendV1Controller(BaseController): + controller_type = "supertrend_v1" + display_name = "SuperTrend V1" + description = "Directional trading with SuperTrend indicator (ATR-based trend following)" + + @classmethod + def get_defaults(cls) -> Dict[str, Any]: + return DEFAULTS.copy() + + @classmethod + def get_fields(cls) -> Dict[str, ControllerField]: + return FIELDS + + @classmethod + def get_field_order(cls) -> List[str]: + return FIELD_ORDER + + @classmethod + def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + 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: + return generate_chart(config, candles_data, current_price) + + @classmethod + def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + return generate_id(config, existing_configs) + + +__all__ = ["SuperTrendV1Controller", "DEFAULTS", "FIELDS", "FIELD_ORDER", "EDITABLE_FIELDS", + "validate_config", "generate_id", "generate_chart", "generate_preview_chart"] diff --git a/handlers/bots/controllers/supertrend_v1/analysis.py b/handlers/bots/controllers/supertrend_v1/analysis.py new file mode 100644 index 00000000..d22b0cab --- /dev/null +++ b/handlers/bots/controllers/supertrend_v1/analysis.py @@ -0,0 +1,327 @@ +""" +SuperTrend V1 analysis utilities. + +Calculates SuperTrend indicator manually (pure Python, no pandas_ta): +- SuperTrend line and direction +- Suggested parameters based on candle data +- Signal statistics +""" + +import math +from typing import Any, Dict, List, Optional, Tuple + + +def calculate_atr(candles: List[Dict[str, Any]], period: int = 14) -> List[float]: + """Calculate ATR series.""" + if len(candles) < period + 1: + return [] + + true_ranges = [] + for i in range(1, len(candles)): + high = float(candles[i].get("high", 0) or 0) + low = float(candles[i].get("low", 0) or 0) + prev_close = float(candles[i - 1].get("close", 0) or 0) + if not all([high, low, prev_close]): + true_ranges.append(0.0) + continue + tr = max(high - low, abs(high - prev_close), abs(low - prev_close)) + true_ranges.append(tr) + + if len(true_ranges) < period: + return [] + + # Wilder's smoothing (RMA) + atr_values = [sum(true_ranges[:period]) / period] + for tr in true_ranges[period:]: + atr_values.append((atr_values[-1] * (period - 1) + tr) / period) + + return atr_values + + +def calculate_supertrend( + candles: List[Dict[str, Any]], + length: int = 20, + multiplier: float = 4.0, +) -> List[Dict[str, Any]]: + """ + Calculate SuperTrend indicator. + + Returns list of dicts with: + - supertrend: SuperTrend line value + - direction: 1 = uptrend (bullish), -1 = downtrend (bearish) + - close: candle close price + - percentage_distance: distance from close to ST line as % + """ + if len(candles) < length + 1: + return [] + + atr_values = calculate_atr(candles, length) + if not atr_values: + return [] + + # Align ATR with candles (ATR starts at index `length`) + atr_start = length + + results = [] + prev_upper = None + prev_lower = None + prev_direction = 1 + + for i in range(len(atr_values)): + candle_idx = i + atr_start + if candle_idx >= len(candles): + break + + high = float(candles[candle_idx].get("high", 0) or 0) + low = float(candles[candle_idx].get("low", 0) or 0) + close = float(candles[candle_idx].get("close", 0) or 0) + atr = atr_values[i] + + hl2 = (high + low) / 2 + basic_upper = hl2 + multiplier * atr + basic_lower = hl2 - multiplier * atr + + # Adjust bands + if prev_upper is None: + upper = basic_upper + lower = basic_lower + else: + upper = basic_upper if basic_upper < prev_upper or float(candles[candle_idx - 1].get("close", 0)) > prev_upper else prev_upper + lower = basic_lower if basic_lower > prev_lower or float(candles[candle_idx - 1].get("close", 0)) < prev_lower else prev_lower + + # Determine direction + if prev_direction == -1: + direction = 1 if close > upper else -1 + else: + direction = -1 if close < lower else 1 + + supertrend = lower if direction == 1 else upper + pct_distance = abs(close - supertrend) / close if close > 0 else 0 + + results.append({ + "supertrend": supertrend, + "direction": direction, + "close": close, + "percentage_distance": pct_distance, + "upper": upper, + "lower": lower, + }) + + prev_upper = upper + prev_lower = lower + prev_direction = direction + + return results + + +def calculate_natr(candles: List[Dict[str, Any]], period: int = 14) -> Optional[float]: + """Calculate Normalized ATR.""" + if not candles or len(candles) < period + 1: + return None + atr_values = calculate_atr(candles, period) + if not atr_values: + return None + current_close = float(candles[-1].get("close", 0) or 0) + return atr_values[-1] / current_close if current_close > 0 else None + + +def suggest_percentage_threshold( + st_results: List[Dict[str, Any]], + signal_percentile: float = 75.0, +) -> float: + """ + Suggest percentage_threshold based on historical distance distribution. + + Logic: use the Nth percentile of historical distances β€” captures most + signal opportunities while filtering out very distant entries. + """ + if not st_results: + return 0.01 + + distances = [r["percentage_distance"] for r in st_results if r["percentage_distance"] > 0] + if not distances: + return 0.01 + + sorted_d = sorted(distances) + idx = max(0, min(int(len(sorted_d) * signal_percentile / 100), len(sorted_d) - 1)) + return round(sorted_d[idx], 4) + + +def analyze_candles_for_supertrend( + candles: List[Dict[str, Any]], + length: int = 20, + multiplier: float = 4.0, + percentage_threshold: float = 0.01, + natr_period: int = 14, +) -> Dict[str, Any]: + """ + Full analysis of candle data for SuperTrend V1. + + Returns: + - current_direction: 1 (up) or -1 (down) + - current_supertrend: current ST line value + - current_pct_distance: current distance from price to ST line + - signal_now: True if current candle would trigger a signal + - natr: Normalized ATR + - suggested_percentage_threshold: auto-suggested threshold + - signal_count_long/short: historical signal counts + - trend_changes: number of direction flips + - pct_time_long/short: % of time in each trend + - analysis_candles: candle count used + """ + result = { + "current_direction": 0, + "current_supertrend": None, + "current_pct_distance": None, + "signal_now": False, + "natr": None, + "suggested_percentage_threshold": percentage_threshold, + "signal_count_long": 0, + "signal_count_short": 0, + "trend_changes": 0, + "pct_time_long": 0.0, + "pct_time_short": 0.0, + "analysis_candles": len(candles), + } + + if not candles or len(candles) < length + natr_period + 1: + return result + + st_results = calculate_supertrend(candles, length, multiplier) + if not st_results: + return result + + # Current state + current = st_results[-1] + result["current_direction"] = current["direction"] + result["current_supertrend"] = round(current["supertrend"], 6) + result["current_pct_distance"] = round(current["percentage_distance"] * 100, 3) + result["signal_now"] = current["percentage_distance"] < percentage_threshold + + # Historical stats + long_count = sum(1 for r in st_results if r["direction"] == 1 and r["percentage_distance"] < percentage_threshold) + short_count = sum(1 for r in st_results if r["direction"] == -1 and r["percentage_distance"] < percentage_threshold) + result["signal_count_long"] = long_count + result["signal_count_short"] = short_count + + # Trend changes + changes = sum(1 for i in range(1, len(st_results)) if st_results[i]["direction"] != st_results[i-1]["direction"]) + result["trend_changes"] = changes + + # % time in each trend + n = len(st_results) + long_time = sum(1 for r in st_results if r["direction"] == 1) + result["pct_time_long"] = round(long_time / n * 100, 1) + result["pct_time_short"] = round((n - long_time) / n * 100, 1) + + # Suggested threshold + result["suggested_percentage_threshold"] = suggest_percentage_threshold(st_results) + + # NATR + result["natr"] = calculate_natr(candles, natr_period) + + return result + +def get_st_strategy_suggestions(natr: float, analysis: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + """ + Ritorna suggerimenti per SuperTrend V1 basati sulla volatilitΓ . + I valori TP/SL e threshold vengono scalati in base alla volatilitΓ  (NATR). + """ + if not natr or natr <= 0: + natr = 0.01 # Default 1% volatility + + # Determina il regime di volatilitΓ  + if natr < 0.005: # <0.5% + vol_regime = "very_low" + vol_mult = 0.7 + elif natr < 0.01: # 0.5-1% + vol_regime = "low" + vol_mult = 1.0 + elif natr < 0.02: # 1-2% + vol_regime = "moderate" + vol_mult = 1.3 + elif natr < 0.03: # 2-3% + vol_regime = "high" + vol_mult = 1.6 + else: # >3% + vol_regime = "very_high" + vol_mult = 2.0 + + # Valori base + base_length = 20 + base_multiplier = 4.0 + base_threshold = 0.01 + base_tp = 0.03 + base_sl = 0.05 + base_ts_activation = 0.015 + base_ts_delta = 0.005 + + return { + "scalping": { + "label": "Target: Scalping (Reattivo)", + "length": 10, # ATR piΓΉ corto β†’ piΓΉ reattivo + "multiplier": 3.0, # Bande piΓΉ strette + "percentage_threshold": round(base_threshold * 0.8, 4), # Soglia piΓΉ stretta + "take_profit": round(base_tp * vol_mult * 0.6, 4), + "stop_loss": round(base_sl * vol_mult * 0.7, 4), + "trailing_stop_activation": round(base_ts_activation * vol_mult * 0.8, 4), + "trailing_stop_delta": round(base_ts_delta * vol_mult * 0.8, 4), + "volatility_regime": vol_regime + }, + "swing": { + "label": "Target: Swing (Filtro stretto)", + "length": 30, # ATR piΓΉ lungo β†’ piΓΉ stabile + "multiplier": 5.0, # Bande piΓΉ larghe + "percentage_threshold": round(base_threshold * 1.5, 4), # Soglia piΓΉ larga + "take_profit": round(base_tp * vol_mult * 1.6, 4), + "stop_loss": round(base_sl * vol_mult * 1.3, 4), + "trailing_stop_activation": round(base_ts_activation * vol_mult * 1.2, 4), + "trailing_stop_delta": round(base_ts_delta * vol_mult * 1.2, 4), + "volatility_regime": vol_regime + }, + "auto": { + "label": "Target: Auto (Analisi Live)", + "length": base_length, + "multiplier": base_multiplier, + "percentage_threshold": analysis.get("suggested_percentage_threshold", base_threshold), + "take_profit": round(base_tp * vol_mult, 4), + "stop_loss": round(base_sl * vol_mult, 4), + "trailing_stop_activation": round(base_ts_activation * vol_mult, 4), + "trailing_stop_delta": round(base_ts_delta * vol_mult, 4), + "volatility_regime": vol_regime + } + } + +def format_supertrend_analysis(analysis: Dict[str, Any]) -> str: + """Format analysis results for display in wizard final step.""" + lines = [] + n_candles = analysis.get("analysis_candles", 0) + direction = analysis.get("current_direction", 0) + st_val = analysis.get("current_supertrend") + pct_dist = analysis.get("current_pct_distance") + signal_now = analysis.get("signal_now", False) + natr = analysis.get("natr") + long_signals = analysis.get("signal_count_long", 0) + short_signals = analysis.get("signal_count_short", 0) + trend_changes = analysis.get("trend_changes", 0) + pct_long = analysis.get("pct_time_long", 0) + pct_short = analysis.get("pct_time_short", 0) + suggested_thr = analysis.get("suggested_percentage_threshold", 0.01) + + dir_str = "πŸ“ˆ UP (Bullish)" if direction == 1 else ("πŸ“‰ DOWN (Bearish)" if direction == -1 else "β€”") + + lines.append(f"SuperTrend analysis ({n_candles} candles):") + lines.append(f" Direction now: {dir_str}") + if st_val is not None: + lines.append(f" ST line: {st_val:.6g}") + if pct_dist is not None: + lines.append(f" Distance: {pct_dist:.3f}% {'βœ… signal active' if signal_now else '⚠️ no signal (too far)'}") + if natr: + lines.append(f" NATR(14): {natr*100:.3f}%") + lines.append(f" % time bullish: {pct_long:.1f}% | bearish: {pct_short:.1f}%") + lines.append(f" Trend changes: {trend_changes}") + lines.append(f" Signals (history): LONG={long_signals} SHORT={short_signals}") + lines.append("") + lines.append(f" β†’ suggested percentage_threshold: {suggested_thr}") + + return "\n".join(lines) diff --git a/handlers/bots/controllers/supertrend_v1/chart.py b/handlers/bots/controllers/supertrend_v1/chart.py new file mode 100644 index 00000000..a8b50116 --- /dev/null +++ b/handlers/bots/controllers/supertrend_v1/chart.py @@ -0,0 +1,519 @@ +""" +SuperTrend V1 chart generation. + +4 panels: + 1. Price – candlesticks + SuperTrend line (green=UP / red=DOWN) + + MA20/MA50/EMA9 + 2. Volume – colored bars + 3. ATR – raw ATR series (Wilder smoothing) showing volatility + 4. Distance – % distance between close and ST line, + with percentage_threshold shown as a dashed line. + Signal fires when distance < threshold. + +Signal logic: + LONG when direction == UP AND distance < percentage_threshold + SHORT when direction == DOWN AND distance < percentage_threshold +""" + +import io +import time +import math +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +from matplotlib.patches import Rectangle + + +# ── PUBLIC API ─────────────────────────────────────────────────────── + +def generate_chart(config, candles_data, current_price=None, **kwargs): + if not candles_data or len(candles_data) < 5: + return _generate_simple_chart(candles_data, current_price) + + timezone = config.get('timezone', 'Europe/Rome') + df = _prepare_dataframe(candles_data, timezone=timezone) + + # ── MOSTRA SOLO ULTIME 96 CANDELE ───────────────────── + MAX_VISIBLE_CANDLES = 96 + + # mantieni dataset completo per ATR/ST + full_df = df.copy() + + # df visualizzato + if len(df) > MAX_VISIBLE_CANDLES: + df = df.tail(MAX_VISIBLE_CANDLES).reset_index(drop=True) + + for col in ['open', 'high', 'low', 'close', 'volume']: + df[col] = pd.to_numeric(df.get(col, 0), errors='coerce').fillna(0) + + length = int(config.get('length', 20)) + multiplier = float(config.get('multiplier', 4.0)) + pct_thr = float(config.get('percentage_threshold', 0.01)) + + # ── INDICATORI ─────────────────────────────────────────────────── + full_df['ma20'] = full_df['close'].rolling(20).mean() + full_df['ma50'] = full_df['close'].rolling(50).mean() + full_df['ema9'] = full_df['close'].ewm(span=9).mean() + + # ── CALCOLI SU DATASET COMPLETO ─────────────────────── + + full_df['atr'] = _calc_atr_rma(full_df, length) + + full_df['st_line'], full_df['st_dir'] = _calc_supertrend( + full_df, + multiplier + ) + + full_df['st_dist'] = ( + (full_df['close'] - full_df['st_line']).abs() + / full_df['close'] + ).fillna(np.nan) + + # ── PRENDI SOLO ULTIME 96 CANDELE VISUALI ──────────── + + df = full_df.tail(MAX_VISIBLE_CANDLES).reset_index(drop=True) + + # ── FIGURA ─────────────────────────────────────────────────────── + fig, (ax1, ax2, ax3, ax4) = plt.subplots( + 4, + 1, + figsize=(22, 14), + sharex=True, + gridspec_kw={ + 'height_ratios': [4.5, 1.2, 1.3, 1.5] + } + ) + fig.patch.set_facecolor('#111111') + dates = mdates.date2num(df['datetime']) + if len(dates) > 1: + candle_width = (dates[1] - dates[0]) * 0.85 + volume_width = (dates[1] - dates[0]) * 0.85 + else: + candle_width = volume_width = 0.0005 + for ax in [ax1, ax2, ax3, ax4]: + + ax.set_facecolor('#111111') + + ax.tick_params(colors='white') + + ax.yaxis.label.set_color('white') + + ax.spines['bottom'].set_color('#444') + ax.spines['top'].set_color('#444') + ax.spines['left'].set_color('#444') + ax.spines['right'].set_color('#444') + + # ── PANNELLO 1: PREZZO + SUPERTREND ────────────────────────────── + for i in range(len(df)): + o, h, l, c = df.iloc[i][['open', 'high', 'low', 'close']] + color = '#2ecc71' if c >= o else '#e74c3c' + ax1.plot([dates[i], dates[i]], [l, h], color=color, linewidth=1.2) + ax1.add_patch(Rectangle( + (dates[i] - candle_width / 2, min(o, c)), + candle_width, abs(c - o) or 1e-8, + color=color + )) + + ax1.plot(df['datetime'], df['ma20'], label='MA20', linewidth=1.2, color='#f39c12' ) + ax1.plot(df['datetime'], df['ma50'], label='MA50', linewidth=1.2, color='#3498db') + ax1.plot(df['datetime'], df['ema9'], label='EMA9', linewidth=1.2, color='#9b59b6') + + # SuperTrend: segmenti colorati per direzione + _plot_supertrend_colored(ax1, df) + + if current_price: + ax1.axhline(y=current_price, linestyle='--', alpha=0.6, color='gold', label='Price') + handles, labels = ax1.get_legend_handles_labels() + legend_map = dict(zip(labels, handles)) + desired_order = ['MA20', 'ST UP', 'MA50', 'ST DOWN', 'EMA9', 'Price'] + ordered_handles = [] + ordered_labels = [] + for label in desired_order: + if label in legend_map: + ordered_handles.append(legend_map[label]) + ordered_labels.append(label) + legend1 = ax1.legend(ordered_handles, ordered_labels, loc='upper left', fontsize=9, ncol=3, framealpha=0) + for text in legend1.get_texts(): + text.set_color('white') + ax1.set_ylabel('Price') + ax1.grid(True, alpha=0.3) + ax1.set_xlim(df['datetime'].min(), df['datetime'].max()) + + # ── PANNELLO 2: VOLUME ─────────────────────────────────────────── + vol_colors = [ + '#2ecc71' if df['close'].iloc[i] >= df['open'].iloc[i] else '#e74c3c' + for i in range(len(df)) + ] + ax2.bar(dates, df['volume'], width=volume_width, color=vol_colors, alpha=0.7) + ax2.set_ylabel('Volume') + ax2.grid(True, alpha=0.3) + + # ── PANNELLO 3: ATR ────────────────────────────────────────────── + ax3.plot(df['datetime'], df['atr'], linewidth=1.5, color='steelblue', label=f'ATR({length})') + first_valid_atr = df['atr'].notna().to_numpy().argmax() + + if first_valid_atr is not None and first_valid_atr > 0: + + ax3.axvspan( + df['datetime'].iloc[0], + df['datetime'].iloc[first_valid_atr], + color='gray', + alpha=0.10, + label='ATR warmup' + ) + ax3.set_ylabel(f'ATR({length})') + legend3 = ax3.legend(loc='upper left', fontsize=9,framealpha=0) + for text in legend3.get_texts(): + text.set_color('white') + ax3.grid(True, alpha=0.3) + + # ── PANNELLO 4: DISTANZA % ─────────────────────────────────────── + # colora la linea: verde se direction UP, rosso se DOWN + dist_vals = df['st_dist'].values + dir_vals = df['st_dir'].values + dt_vals = df['datetime'].values + + # Plotta segmenti per direzione + up_label_added = False + down_label_added = False + + # Evidenzia area warmup indicatori + first_valid = df['st_dist'].first_valid_index() + + if first_valid is not None and first_valid > 0: + + ax4.axvspan( + df['datetime'].iloc[0], + df['datetime'].iloc[first_valid], + color='gray', + alpha=0.10, + label='Indicator warmup' + ) + for i in range(1, len(df)): + + if np.isnan(dist_vals[i]) or np.isnan(dist_vals[i - 1]): + continue + + is_up = dir_vals[i] == 1 + + seg_color = '#2ecc71' if is_up else '#e74c3c' + + label = None + + if is_up and not up_label_added: + label = 'Distance UP' + up_label_added = True + + elif not is_up and not down_label_added: + label = 'Distance DOWN' + down_label_added = True + + ax4.plot( + [dt_vals[i - 1], dt_vals[i]], + [dist_vals[i - 1], dist_vals[i]], + color=seg_color, + linewidth=1.4, + label=label + ) + + # Soglia: zona verde sotto la linea = signal attivo + ax4.axhline(pct_thr, linestyle='--', color='#f1c40f', linewidth=1.8, + label=f'Threshold {pct_thr*100:.2f}%') + ax4.fill_between(df['datetime'], 0, pct_thr, alpha=0.2, color='green', + label='Signal zone') + + ax4.set_ylabel('Distance %') + ax4.set_ylim(bottom=0) + ax4.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f'{v*100:.2f}%')) + legend4 = ax4.legend(loc='upper left', fontsize=9, ncol=2, framealpha=0) + for text in legend4.get_texts(): + text.set_color('white') + ax4.grid(True, alpha=0.3) + +# ── FIX ASSE X BASATO SUL TIMEFRAME ─────────────────────────────── + + interval = config.get('interval', '5m') + # Mapping personalizzato timeframe -> tick principali + if interval == '1m': + # Tick ogni 15 minuti + locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45]) + formatter = mdates.DateFormatter('%H:%M') + + # Tick minori ogni 5 minuti + minor_locator = mdates.MinuteLocator(interval=5) + + elif interval == '5m': + # Tick ogni ora + locator = mdates.HourLocator(interval=1) + formatter = mdates.DateFormatter('%H:%M') + + # Tick minori ogni 15 minuti + minor_locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45]) + + elif interval == '15m': + + locator = mdates.HourLocator(interval=3) + formatter = mdates.DateFormatter('%d %b - %H:%M') + minor_locator = mdates.HourLocator(interval=1) + + elif interval == '1h': + + locator = mdates.HourLocator(interval=12) + formatter = mdates.DateFormatter('%d %b - %H:%M') + minor_locator = mdates.HourLocator(interval=3) + + elif interval == '8h': + + locator = mdates.DayLocator(interval=4) + formatter = mdates.DateFormatter('%b%d') + minor_locator = mdates.DayLocator(interval=1) + + else: + # fallback intelligente + locator = mdates.AutoDateLocator(minticks=6, maxticks=12) + formatter = mdates.ConciseDateFormatter(locator) + minor_locator = None + + # Applica a TUTTI gli assi (non solo ax4) + ax1.tick_params(labelbottom=False) + ax2.tick_params(labelbottom=False) + ax3.tick_params(labelbottom=False) + for ax in [ax1, ax2, ax3, ax4]: + ax.grid(True, axis='x', which='major', linestyle='--', alpha=0.30, linewidth=0.8) + ax.grid(False, which='minor', axis='x') + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(formatter) + + if minor_locator: + ax.xaxis.set_minor_locator(minor_locator) + + plt.setp( + ax.xaxis.get_majorticklabels(), + rotation=0, + ha='center' + ) + # Grid orizzontale + ax.grid(True, which='major', axis='y', alpha=0.30) + # Grid verticale tratteggiato + ax.grid(True, which='major', axis='x', linestyle='--', alpha=0.15) + + # Minor grid molto leggera + ax.grid(True, which='minor', axis='y', alpha=0.05) + # ── TITOLO ─────────────────────────────────────────────────────── + interval = config.get('interval', '5m') + fig.suptitle( + f"{config.get('trading_pair', 'Unknown')} - SuperTrend V1 " + f"(length={length}, mult={multiplier} | {interval})", + fontsize=13, color='white' + ) + + plt.subplots_adjust(hspace=0.05, top=0.94, bottom=0.06) + buf = io.BytesIO() + plt.savefig(buf, format='png', dpi=120, bbox_inches='tight') + plt.close(fig) + buf.seek(0) + return buf + + +def generate_preview_chart(config, candles_data, current_price=None, **kwargs): + return generate_chart(config, candles_data, current_price) + + +# ── HELPERS ────────────────────────────────────────────────────────── + +def _prepare_dataframe(candles, timezone=None): + if timezone is None: + # Prende il fuso orario del sistema + timezone = time.tzname[0] + df = pd.DataFrame(candles) + + # Cerca colonna timestamp + ts_col = next((c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), None) + + if ts_col: + # Converti timestamp + sample = df[ts_col].iloc[0] + if isinstance(sample, (int, float)): + # Determina se Γ¨ millisecondi o secondi + if sample > 10**12: # nanosecondi + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='ns', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + elif sample > 10**10: # millisecondi (dopo il 1970) + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='ms', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + else: # secondi + df['datetime'] = ( + pd.to_datetime(df[ts_col], unit='s', utc=True) + .dt.tz_convert(timezone) + .dt.tz_localize(None) + ) + else: + df['datetime'] = (pd.to_datetime(df[ts_col], utc=True).dt.tz_convert(timezone).dt.tz_localize(None)) + else: + # Fallback: crea date sequenziali usando l'intervallo dalla config + # NOTA: questo Γ¨ un fallback, idealmente dovresti avere timestamp reali + freq = config.get('interval', '5m') if 'config' in locals() else '5m' + df['datetime'] = pd.date_range( + end=pd.Timestamp.now(), + periods=len(df), + freq=freq + ) + + return df.sort_values('datetime').reset_index(drop=True) + + +def _calc_atr_rma(df: pd.DataFrame, period: int) -> pd.Series: + """ATR with Wilder's RMA smoothing, matching analysis.py logic.""" + high = df['high'] + low = df['low'] + close = df['close'] + + prev_close = close.shift(1) + tr = pd.concat([ + high - low, + (high - prev_close).abs(), + (low - prev_close).abs(), + ], axis=1).max(axis=1) + + # RMA (Wilder): seed with simple mean, then smooth + atr = tr.copy().astype(float) + seed_end = period # first valid ATR at index `period` + if len(tr) < period + 1: + return pd.Series([np.nan] * len(df), index=df.index) + + seed = tr.iloc[1:period + 1].mean() # skip row 0 (no prev_close) + atr.iloc[:period + 1] = np.nan + atr.iloc[period] = seed + alpha = 1.0 / period + for i in range(period + 1, len(atr)): + atr.iloc[i] = atr.iloc[i - 1] * (1 - alpha) + tr.iloc[i] * alpha + + return atr + + +def _calc_supertrend(df: pd.DataFrame, multiplier: float): + """ + Compute SuperTrend line and direction series. + Returns (st_line: pd.Series, st_dir: pd.Series) + direction: 1 = UP (bullish), -1 = DOWN (bearish) + """ + hl2 = (df['high'] + df['low']) / 2 + atr = df['atr'] + + basic_upper = hl2 + multiplier * atr + basic_lower = hl2 - multiplier * atr + + n = len(df) + upper = np.full(n, np.nan) + lower = np.full(n, np.nan) + st = np.full(n, np.nan) + direc = np.zeros(n, dtype=int) + + close = df['close'].values + bu = basic_upper.values + bl = basic_lower.values + + # find first valid index (where atr is not nan) + start = df['atr'].first_valid_index() + if start is None: + return pd.Series(st, index=df.index), pd.Series(direc, index=df.index) + si = df.index.get_loc(start) + + upper[si] = bu[si] + lower[si] = bl[si] + direc[si] = 1 + + for i in range(si + 1, n): + # tighten upper band + upper[i] = bu[i] if bu[i] < upper[i - 1] or close[i - 1] > upper[i - 1] else upper[i - 1] + # widen lower band + lower[i] = bl[i] if bl[i] > lower[i - 1] or close[i - 1] < lower[i - 1] else lower[i - 1] + + if direc[i - 1] == -1: + direc[i] = 1 if close[i] > upper[i] else -1 + else: + direc[i] = -1 if close[i] < lower[i] else 1 + + # ST line: lower when UP, upper when DOWN + for i in range(si, n): + st[i] = lower[i] if direc[i] == 1 else upper[i] + + # set pre-start to nan/0 + upper[:si] = np.nan + lower[:si] = np.nan + st[:si] = np.nan + direc[:si] = 0 + + return ( + pd.Series(st, index=df.index), + pd.Series(direc, index=df.index), + ) + + +def _plot_supertrend_colored(ax, df: pd.DataFrame): + + dt = df['datetime'].values + st = df['st_line'].values + direc = df['st_dir'].values + + up_added = False + down_added = False + + for i in range(1, len(df)): + + if np.isnan(st[i]) or np.isnan(st[i - 1]): + continue + + is_up = direc[i] == 1 + + color = '#27ae60' if is_up else '#c0392b' + + label = None + + if is_up and not up_added: + label = 'ST UP' + up_added = True + + elif not is_up and not down_added: + label = 'ST DOWN' + down_added = True + + ax.plot( + [dt[i - 1], dt[i]], + [st[i - 1], st[i]], + color=color, + linewidth=2.4, + solid_capstyle='round', + label=label + ) + +def _generate_simple_chart(candles_data, current_price): + if not candles_data: + return io.BytesIO() + df = _prepare_dataframe(candles_data, timezone=timezone) + +# ── LIMITA CANDELE VISUALIZZATE ──────────────────────────────────── + MAX_VISIBLE_CANDLES = 96 + if len(df) > MAX_VISIBLE_CANDLES: + df = df.tail(MAX_VISIBLE_CANDLES).reset_index(drop=True) + + fig, ax = plt.subplots(figsize=(10, 6)) + ax.plot(df['datetime'], pd.to_numeric(df.get('close', pd.Series(dtype=float)), errors='coerce')) + if current_price: + ax.axhline(y=current_price, linestyle='--') + locator = mdates.AutoDateLocator(minticks=6, maxticks=12) + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator)) + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format='png') + plt.close(fig) + buf.seek(0) + return buf diff --git a/handlers/bots/controllers/supertrend_v1/config.py b/handlers/bots/controllers/supertrend_v1/config.py new file mode 100644 index 00000000..8355752d --- /dev/null +++ b/handlers/bots/controllers/supertrend_v1/config.py @@ -0,0 +1,132 @@ +""" +SuperTrend V1 controller configuration. + +Directional trading strategy using SuperTrend indicator: +- LONG when SuperTrend direction == UP AND price is within percentage_threshold of the line +- SHORT when SuperTrend direction == DOWN AND price is within percentage_threshold of the line +""" + +from typing import Any, Dict, List, Optional, Tuple + +from .._base import ControllerField + +DEFAULTS: Dict[str, Any] = { + "controller_name": "supertrend_v1", + "controller_type": "directional_trading", + "id": "", + # Base fields + "manual_kill_switch": None, + "candles_config": [], + # Connector + "connector_name": "", + "trading_pair": "", + "total_amount_quote": 1000, + "leverage": 1, + "position_mode": "HEDGE", + # DirectionalTradingControllerConfigBase fields + "max_executors_per_side": 1, + "cooldown_time": 60, + "stop_loss": 0.05, + "take_profit": 0.03, + "take_profit_order_type": 2, + "time_limit": None, + # Trailing stop + "trailing_stop": { + "activation_price": 0.015, + "trailing_delta": 0.005, + }, + # Candles config + "candles_connector": "", + "candles_trading_pair": "", + "interval": "5m", + # SuperTrend parameters + "length": 20, + "multiplier": 4.0, + "percentage_threshold": 0.01, +} + +FIELDS: Dict[str, ControllerField] = { + "id": ControllerField(name="id", label="Config ID", type="str", required=True, hint="Auto-generated"), + "connector_name": ControllerField(name="connector_name", label="Connector", type="str", required=True, hint="Exchange connector"), + "trading_pair": ControllerField(name="trading_pair", label="Trading Pair", type="str", required=True, hint="e.g. BTC-USDT"), + "leverage": ControllerField(name="leverage", label="Leverage", type="int", required=True, hint="e.g. 1, 5, 10", default=1), + "position_mode": ControllerField(name="position_mode", label="Position Mode", type="str", required=False, hint="HEDGE or ONEWAY", default="HEDGE"), + "total_amount_quote": ControllerField(name="total_amount_quote", label="Total Amount (Quote)", type="float", required=True, hint="e.g. 1000 USDT"), + "max_executors_per_side": ControllerField(name="max_executors_per_side", label="Max Executors/Side", type="int", required=False, hint="Max concurrent positions per side", default=1), + "cooldown_time": ControllerField(name="cooldown_time", label="Cooldown Time (s)", type="int", required=False, hint="Seconds between new executors", default=60), + "stop_loss": ControllerField(name="stop_loss", label="Stop Loss", type="float", required=False, hint="e.g. 0.05 = 5%", default=0.05), + "take_profit": ControllerField(name="take_profit", label="Take Profit", type="float", required=False, hint="e.g. 0.03 = 3%", default=0.03), + "take_profit_order_type": ControllerField(name="take_profit_order_type", label="TP Order Type", type="int", required=False, hint="1=Market, 2=Limit, 3=Limit Maker", default=2), + "time_limit": ControllerField(name="time_limit", label="Time Limit (s)", type="int", required=False, hint="Max executor lifetime (None = no limit)", default=None), + "candles_connector": ControllerField(name="candles_connector", label="Candles Connector", type="str", required=False, hint="Leave empty to use same as connector", default=""), + "candles_trading_pair": ControllerField(name="candles_trading_pair", label="Candles Pair", type="str", required=False, hint="Leave empty to use same as trading pair", default=""), + "interval": ControllerField(name="interval", label="Candle Interval", type="str", required=True, hint="e.g. 1m, 5m, 1h, 8h", default="5m"), + "length": ControllerField(name="length", label="SuperTrend Length", type="int", required=False, hint="ATR period (default: 20)", default=20), + "multiplier": ControllerField(name="multiplier", label="SuperTrend Multiplier", type="float", required=False, hint="ATR multiplier (default: 4.0)", default=4.0), + "percentage_threshold": ControllerField(name="percentage_threshold", label="% Threshold", type="float", required=False, hint="Max distance from ST line to signal (e.g. 0.01 = 1%)", default=0.01), + "manual_kill_switch": ControllerField(name="manual_kill_switch", label="Kill Switch", type="bool", required=False, hint="Manual kill switch", default=None), +} + +FIELD_ORDER: List[str] = [ + "id", "connector_name", "trading_pair", "leverage", "position_mode", + "total_amount_quote", "max_executors_per_side", "cooldown_time", + "stop_loss", "take_profit", "take_profit_order_type", "time_limit", + "candles_connector", "candles_trading_pair", "interval", + "length", "multiplier", "percentage_threshold", + "manual_kill_switch", +] + +EDITABLE_FIELDS: List[str] = [ + "connector_name", "trading_pair", "total_amount_quote", "leverage", + "max_executors_per_side", "cooldown_time", + "stop_loss", "take_profit", "take_profit_order_type", + "trailing_stop_activation", "trailing_stop_delta", + "candles_connector", "candles_trading_pair", "interval", + "length", "multiplier", "percentage_threshold", +] + +def get_flat_fields(config: Dict[str, Any]) -> Dict[str, Any]: + """Estrae i campi in formato piatto per l'editing, gestendo trailing_stop.""" + trailing = config.get("trailing_stop", {}) + # Copia i campi esistenti (che sono giΓ  piatti) + flat = dict(config) + # Aggiungi i due campi virtuali + flat["trailing_stop_activation"] = trailing.get("activation_price", 0.015) + flat["trailing_stop_delta"] = trailing.get("trailing_delta", 0.005) + # Rimuovi il dizionario originale per non mostrarlo come campo separato + flat.pop("trailing_stop", None) + return flat + + +def apply_flat_fields(config: Dict[str, Any], updates: Dict[str, Any]) -> None: + """Applica gli aggiornamenti, riconvertendo trailing_stop_activation/delta.""" + for key, value in updates.items(): + if key == "trailing_stop_activation": + if "trailing_stop" not in config: + config["trailing_stop"] = {} + config["trailing_stop"]["activation_price"] = value + elif key == "trailing_stop_delta": + if "trailing_stop" not in config: + config["trailing_stop"] = {} + config["trailing_stop"]["trailing_delta"] = value + else: + config[key] = value + +def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + required = ["connector_name", "trading_pair", "total_amount_quote"] + for field in required: + if not config.get(field): + return False, f"Missing required field: {field}" + return True, None + + +def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + max_num = 0 + for cfg in existing_configs: + parts = cfg.get("id", "").split("_", 1) + if parts and parts[0].isdigit(): + max_num = max(max_num, int(parts[0])) + seq = str(max_num + 1).zfill(3) + connector = config.get("connector_name", "unknown").replace("_perpetual", "").replace("_spot", "") + pair = config.get("trading_pair", "UNKNOWN").upper() + return f"{seq}_st_{connector}_{pair}" diff --git a/handlers/bots/controllers/xemm_multiple_levels/__init__.py b/handlers/bots/controllers/xemm_multiple_levels/__init__.py new file mode 100644 index 00000000..b0695ccd --- /dev/null +++ b/handlers/bots/controllers/xemm_multiple_levels/__init__.py @@ -0,0 +1,42 @@ +"""XEMM Multiple Levels Controller Module - Cross-exchange market making.""" + +import io +from typing import Any, Dict, List, Optional, Tuple + +from .._base import BaseController, ControllerField +from .chart import generate_chart, generate_preview_chart +from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config + + +class XEMMMultipleLevelsController(BaseController): + controller_type = "xemm_multiple_levels" + display_name = "XEMM Multi Levels" + description = "Cross-exchange market making at multiple profitability levels" + + @classmethod + def get_defaults(cls) -> Dict[str, Any]: + return DEFAULTS.copy() + + @classmethod + def get_fields(cls) -> Dict[str, ControllerField]: + return FIELDS + + @classmethod + def get_field_order(cls) -> List[str]: + return FIELD_ORDER + + @classmethod + def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + 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: + return generate_chart(config, candles_data, current_price) + + @classmethod + def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + return generate_id(config, existing_configs) + + +__all__ = ["XEMMMultipleLevelsController", "DEFAULTS", "FIELDS", "FIELD_ORDER", "EDITABLE_FIELDS", + "validate_config", "generate_id", "generate_chart", "generate_preview_chart"] diff --git a/handlers/bots/controllers/xemm_multiple_levels/chart.py b/handlers/bots/controllers/xemm_multiple_levels/chart.py new file mode 100644 index 00000000..a1db249f --- /dev/null +++ b/handlers/bots/controllers/xemm_multiple_levels/chart.py @@ -0,0 +1,41 @@ +"""XEMM Multiple Levels chart - simple candlestick of maker pair.""" + +import io +from typing import Any, Dict, List, Optional + +from handlers.dex.visualizations import generate_candlestick_chart + + +def generate_chart( + config: Dict[str, Any], + candles_data: List[Dict[str, Any]], + current_price: Optional[float] = None, +) -> io.BytesIO: + maker = config.get("maker_connector", "") + taker = config.get("taker_connector", "") + pair = config.get("maker_trading_pair", "Unknown") + title = f"XEMM: {maker} β†’ {taker} | {pair}" + data = candles_data if isinstance(candles_data, list) else candles_data.get("data", []) + + # Aggiungi linea del prezzo corrente se disponibile + hlines = [] + if current_price: + hlines.append({ + "price": current_price, + "color": "blue", + "width": 1, + "label": "Current" + }) + + return generate_candlestick_chart( + candles=data, + title=title, + current_price=current_price, + hlines=hlines, + hrects=[] + ) + + +def generate_preview_chart(config, candles_data, current_price=None): + """Alias for generate_chart for compatibility""" + return generate_chart(config, candles_data, current_price) diff --git a/handlers/bots/controllers/xemm_multiple_levels/config.py b/handlers/bots/controllers/xemm_multiple_levels/config.py new file mode 100644 index 00000000..dc8a4473 --- /dev/null +++ b/handlers/bots/controllers/xemm_multiple_levels/config.py @@ -0,0 +1,203 @@ +""" +XEMM Multiple Levels controller configuration. + +Cross-exchange market making: places limit orders on a maker exchange +(less liquid) and hedges them instantly on a taker exchange (more liquid), +at multiple profitability target levels. + +buy_levels_targets_amount / sell_levels_targets_amount format: + "target_profit1,amount1-target_profit2,amount2-..." + e.g. "0.003,10-0.006,20-0.009,30" + - target_profit: target profitability for this level (e.g. 0.003 = 0.3%) + - amount: relative weight (proportional, not absolute USDT) + Actual order size = (level_weight / total_weight) * (total_amount_quote * 0.5) + +min_profitability / max_profitability: + Range around each target: + - actual min = target - min_profitability + - actual max = target + max_profitability + +Gas fees for DEX taker connectors are handled automatically by hummingbot. +""" + +from typing import Any, Dict, List, Optional, Tuple + +from .._base import ControllerField + +DEFAULTS: Dict[str, Any] = { + "controller_name": "xemm_multiple_levels", + "controller_type": "generic", + "id": "", + "total_amount_quote": 1000, + # Maker = less liquid CEX where limit orders are placed + "maker_connector": "mexc", + "maker_trading_pair": "PEPE-USDT", + # Taker = more liquid CEX/DEX where hedge orders are filled + "taker_connector": "binance", + "taker_trading_pair": "PEPE-USDT", + # Levels: "target_profit,weight-target_profit,weight-..." + "buy_levels_targets_amount": "0.003,10-0.006,20-0.009,30", + "sell_levels_targets_amount": "0.003,10-0.006,20-0.009,30", + # Profitability range around each target level + "min_profitability": 0.003, + "max_profitability": 0.01, + "max_executors_imbalance": 1, + # Base fields from ControllerConfigBase + "manual_kill_switch": None, + "candles_config": [], +} + +FIELDS: Dict[str, ControllerField] = { + "id": ControllerField( + name="id", label="Config ID", type="str", required=True, hint="Auto-generated" + ), + "total_amount_quote": ControllerField( + name="total_amount_quote", label="Total Amount (Quote)", type="float", + required=True, hint="Total capital β€” 50% buy side, 50% sell side" + ), + "maker_connector": ControllerField( + name="maker_connector", label="Maker Exchange", type="str", + required=True, hint="Less liquid CEX where limit orders are placed (e.g. mexc)" + ), + "maker_trading_pair": ControllerField( + name="maker_trading_pair", label="Maker Pair", type="str", + required=True, hint="e.g. PEPE-USDT" + ), + "taker_connector": ControllerField( + name="taker_connector", label="Taker Exchange", type="str", + required=True, hint="More liquid CEX/DEX for hedging (e.g. binance)" + ), + "taker_trading_pair": ControllerField( + name="taker_trading_pair", label="Taker Pair", type="str", + required=True, hint="Usually same as maker pair" + ), + "buy_levels_targets_amount": ControllerField( + name="buy_levels_targets_amount", label="Buy Levels", type="str", + required=True, + hint="Format: profit,weight-profit,weight (e.g. 0.003,10-0.006,20-0.009,30)", + default="0.003,10-0.006,20-0.009,30" + ), + "sell_levels_targets_amount": ControllerField( + name="sell_levels_targets_amount", label="Sell Levels", type="str", + required=True, + hint="Format: profit,weight-profit,weight (e.g. 0.003,10-0.006,20-0.009,30)", + default="0.003,10-0.006,20-0.009,30" + ), + "min_profitability": ControllerField( + name="min_profitability", label="Min Profitability", type="float", + required=False, + hint="Subtracted from each target level (e.g. 0.003 = 0.3%)", default=0.003 + ), + "max_profitability": ControllerField( + name="max_profitability", label="Max Profitability", type="float", + required=False, + hint="Added to each target level (e.g. 0.01 = 1%)", default=0.01 + ), + "max_executors_imbalance": ControllerField( + name="max_executors_imbalance", label="Max Imbalance", type="int", + required=False, hint="Max buy/sell imbalance before pausing (default: 1)", default=1 + ), + "manual_kill_switch": ControllerField( + name="manual_kill_switch", label="Kill Switch", type="bool", + required=False, hint="Manual kill switch", default=None + ), +} + +FIELD_ORDER: List[str] = [ + "id", "total_amount_quote", + "maker_connector", "maker_trading_pair", + "taker_connector", "taker_trading_pair", + "buy_levels_targets_amount", "sell_levels_targets_amount", + "min_profitability", "max_profitability", + "max_executors_imbalance", "manual_kill_switch", +] + +EDITABLE_FIELDS: List[str] = [ + "total_amount_quote", + "maker_connector", "maker_trading_pair", + "taker_connector", "taker_trading_pair", + "buy_levels_targets_amount", "sell_levels_targets_amount", + "min_profitability", "max_profitability", + "max_executors_imbalance", +] + + +def parse_levels(levels_str: str) -> List[List[float]]: + """ + Parse levels string into list of [target_profit, weight] pairs. + e.g. "0.003,10-0.006,20" -> [[0.003, 10], [0.006, 20]] + """ + try: + result = [] + for part in str(levels_str).split("-"): + values = part.strip().split(",") + if len(values) == 2: + result.append([float(values[0]), float(values[1])]) + return result + except Exception: + return [[0.003, 10], [0.006, 20], [0.009, 30]] + + +def format_levels(levels: List[List[float]]) -> str: + """Convert levels list back to string format.""" + return "-".join(f"{p},{a}" for p, a in levels) + + +def suggest_levels_from_spread( + spread_pct: float, total_amount: float, num_levels: int = 3 +) -> str: + """ + Suggest levels based on observed spread between maker and taker. + Each level targets a fraction of the spread. + + Args: + spread_pct: Current spread as decimal (e.g. 0.005 = 0.5%) + total_amount: Total amount per side + num_levels: Number of levels to generate + + Returns: + Levels string in format "profit,weight-profit,weight-..." + """ + if spread_pct <= 0: + return "0.003,10-0.006,20-0.009,30" + + # Generate levels at 30%, 60%, 90% of spread + multipliers = [0.3, 0.6, 0.9][:num_levels] + weights = [10, 20, 30][:num_levels] + + levels = [] + for mult, weight in zip(multipliers, weights): + target = round(spread_pct * mult, 4) + target = max(target, 0.001) # minimum 0.1% + levels.append(f"{target},{weight}") + + return "-".join(levels) + + +def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + required = ["maker_connector", "maker_trading_pair", + "taker_connector", "taker_trading_pair", "total_amount_quote"] + for field in required: + if not config.get(field): + return False, f"Missing required field: {field}" + for field in ["buy_levels_targets_amount", "sell_levels_targets_amount"]: + val = config.get(field, "") + if val: + try: + [list(map(float, x.split(","))) for x in str(val).split("-")] + except Exception: + return False, f"Invalid format for {field}. Use: profit,weight-profit,weight" + return True, None + + +def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str: + max_num = 0 + for cfg in existing_configs: + parts = cfg.get("id", "").split("_", 1) + if parts and parts[0].isdigit(): + max_num = max(max_num, int(parts[0])) + seq = str(max_num + 1).zfill(3) + maker = config.get("maker_connector", "maker").replace("_perpetual", "").replace("_spot", "") + taker = config.get("taker_connector", "taker").replace("_perpetual", "").replace("_spot", "") + pair = config.get("maker_trading_pair", "UNKNOWN").split("-")[0] + return f"{seq}_xemm_{maker}_{taker}_{pair}" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..29ebbe3a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "condor", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}