diff --git a/bots/controllers/generic/pmm_mister.py b/bots/controllers/generic/pmm_mister.py index 84931b64..75b555a9 100644 --- a/bots/controllers/generic/pmm_mister.py +++ b/bots/controllers/generic/pmm_mister.py @@ -45,6 +45,7 @@ class PMMisterConfig(ControllerConfigBase): leverage: int = Field(default=20, json_schema_extra={"is_updatable": True}) position_mode: PositionMode = Field(default="HEDGE") take_profit: Optional[Decimal] = Field(default=Decimal("0.0001"), gt=0, json_schema_extra={"is_updatable": True}) + open_order_type: Optional[OrderType] = Field(default="LIMIT_MAKER", json_schema_extra={"is_updatable": True}) take_profit_order_type: Optional[OrderType] = Field(default="LIMIT_MAKER", json_schema_extra={"is_updatable": True}) max_active_executors_by_level: Optional[int] = Field(default=4, json_schema_extra={"is_updatable": True}) tick_mode: bool = Field(default=False, json_schema_extra={"is_updatable": True}) @@ -58,7 +59,7 @@ def validate_target(cls, v): return Decimal(v) return v - @field_validator('take_profit_order_type', mode="before") + @field_validator('open_order_type', 'take_profit_order_type', mode="before") @classmethod def validate_order_type(cls, v) -> OrderType: if isinstance(v, OrderType): @@ -114,7 +115,7 @@ def triple_barrier_config(self) -> TripleBarrierConfig: return TripleBarrierConfig( take_profit=self.take_profit, trailing_stop=None, - open_order_type=OrderType.LIMIT_MAKER, + open_order_type=self.open_order_type, take_profit_order_type=self.take_profit_order_type, stop_loss_order_type=OrderType.MARKET, time_limit_order_type=OrderType.MARKET diff --git a/bots/scripts/v2_with_controllers.py b/bots/scripts/v2_with_controllers.py index 0dd0d8ce..2ad0b625 100644 --- a/bots/scripts/v2_with_controllers.py +++ b/bots/scripts/v2_with_controllers.py @@ -4,7 +4,6 @@ from hummingbot.client.hummingbot_application import HummingbotApplication from hummingbot.connector.connector_base import ConnectorBase - from hummingbot.core.event.events import MarketOrderFailureEvent from hummingbot.data_feed.candles_feed.data_types import CandlesConfig from hummingbot.strategy.strategy_v2_base import StrategyV2Base, StrategyV2ConfigBase @@ -89,10 +88,20 @@ def check_max_global_drawdown(self): self._is_stop_triggered = True HummingbotApplication.main_application().stop() + def get_controller_report(self, controller_id: str) -> dict: + """ + Get the full report for a controller including performance and custom info. + """ + performance_report = self.controller_reports.get(controller_id, {}).get("performance") + return { + "performance": performance_report.dict() if performance_report else {}, + "custom_info": self.controllers[controller_id].get_custom_info() + } + def send_performance_report(self): if self.current_timestamp - self._last_performance_report_timestamp >= self.performance_report_interval and self._pub: - performance_reports = {controller_id: self.get_performance_report(controller_id).dict() for controller_id in self.controllers.keys()} - self._pub(performance_reports) + controller_reports = {controller_id: self.get_controller_report(controller_id) for controller_id in self.controllers.keys()} + self._pub(controller_reports) self._last_performance_report_timestamp = self.current_timestamp def check_manual_kill_switch(self): @@ -142,9 +151,10 @@ def apply_initial_setting(self): if self.is_perpetual(config_dict["connector_name"]): if "position_mode" in config_dict: connectors_position_mode[config_dict["connector_name"]] = config_dict["position_mode"] - if "leverage" in config_dict: - self.connectors[config_dict["connector_name"]].set_leverage(leverage=config_dict["leverage"], - trading_pair=config_dict["trading_pair"]) + if "leverage" in config_dict and "trading_pair" in config_dict: + self.connectors[config_dict["connector_name"]].set_leverage( + leverage=config_dict["leverage"], + trading_pair=config_dict["trading_pair"]) for connector_name, position_mode in connectors_position_mode.items(): self.connectors[connector_name].set_position_mode(position_mode) @@ -152,7 +162,7 @@ def did_fail_order(self, order_failed_event: MarketOrderFailureEvent): """ Handle order failure events by logging the error and stopping the strategy if necessary. """ - if "position side" in order_failed_event.error_message.lower(): + if order_failed_event.error_message and "position side" in order_failed_event.error_message.lower(): connectors_position_mode = {} for controller_id, controller in self.controllers.items(): config_dict = controller.config.model_dump() diff --git a/models/__init__.py b/models/__init__.py index 2c4694ab..bb64637b 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -105,6 +105,9 @@ from .gateway import ( GatewayConfig, GatewayStatus, + CreateWalletRequest, + ShowPrivateKeyRequest, + SendTransactionRequest, GatewayWalletCredential, GatewayWalletInfo, GatewayBalanceRequest, @@ -267,6 +270,9 @@ # Gateway models "GatewayConfig", "GatewayStatus", + "CreateWalletRequest", + "ShowPrivateKeyRequest", + "SendTransactionRequest", "GatewayWalletCredential", "GatewayWalletInfo", "GatewayBalanceRequest", diff --git a/models/gateway.py b/models/gateway.py index 042711b4..11b97132 100644 --- a/models/gateway.py +++ b/models/gateway.py @@ -27,6 +27,28 @@ class GatewayStatus(BaseModel): # Wallet Management Models # ============================================ +class CreateWalletRequest(BaseModel): + """Request to create a new wallet in Gateway""" + chain: str = Field(description="Blockchain chain (e.g., 'solana', 'ethereum')") + set_default: bool = Field(default=True, description="Set as default wallet for this chain") + + +class ShowPrivateKeyRequest(BaseModel): + """Request to show private key for a wallet""" + chain: str = Field(description="Blockchain chain (e.g., 'solana', 'ethereum')") + address: str = Field(description="Wallet address") + passphrase: str = Field(description="Gateway passphrase for decryption") + + +class SendTransactionRequest(BaseModel): + """Request to send a native token transaction""" + chain: str = Field(description="Blockchain chain (e.g., 'solana', 'ethereum')") + network: str = Field(description="Network (e.g., 'mainnet-beta', 'mainnet')") + address: str = Field(description="Sender wallet address") + to_address: str = Field(description="Recipient address") + amount: str = Field(description="Amount to send (in native token units)") + + class GatewayWalletCredential(BaseModel): """Credentials for connecting a Gateway wallet""" chain: str = Field(description="Blockchain chain (e.g., 'solana', 'ethereum')") diff --git a/routers/bot_orchestration.py b/routers/bot_orchestration.py index 86988255..843289bc 100644 --- a/routers/bot_orchestration.py +++ b/routers/bot_orchestration.py @@ -163,15 +163,20 @@ async def stop_bot( Returns: Dictionary with status and response from bot stop operation """ + # Capture final status BEFORE stopping (performance data is cleared on stop) + final_status = None + try: + final_status = bots_manager.get_bot_status(action.bot_name) + logger.info(f"Captured final status for {action.bot_name} before stopping") + except Exception as e: + logger.warning(f"Failed to capture final status for {action.bot_name}: {e}") + response = await bots_manager.stop_bot(action.bot_name, skip_order_cancellation=action.skip_order_cancellation, async_backend=action.async_backend) - + # Update bot run status to STOPPED if stop was successful if response.get("success"): try: - # Try to get bot status for final status data - final_status = bots_manager.get_bot_status(action.bot_name) - async with db_manager.get_session_context() as session: bot_run_repo = BotRunRepository(session) await bot_run_repo.update_bot_run_stopped( diff --git a/routers/gateway.py b/routers/gateway.py index 105933b5..8bca7309 100644 --- a/routers/gateway.py +++ b/routers/gateway.py @@ -2,7 +2,15 @@ from typing import Optional, Dict, List import re -from models import GatewayConfig, GatewayStatus, AddPoolRequest, AddTokenRequest +from models import ( + GatewayConfig, + GatewayStatus, + AddPoolRequest, + AddTokenRequest, + CreateWalletRequest, + ShowPrivateKeyRequest, + SendTransactionRequest, +) from services.gateway_service import GatewayService from services.accounts_service import AccountsService from deps import get_gateway_service, get_accounts_service @@ -663,3 +671,146 @@ async def delete_network_token( raise except Exception as e: raise HTTPException(status_code=500, detail=f"Error deleting token: {str(e)}") + + +# ============================================ +# Wallet Management +# ============================================ + +@router.post("/wallets/create") +async def create_wallet( + request: CreateWalletRequest, + accounts_service: AccountsService = Depends(get_accounts_service) +) -> Dict: + """ + Create a new wallet in Gateway. + + Args: + request: Contains chain and set_default flag + + Returns: + Dict with address and chain of the created wallet. + + Example: POST /gateway/wallets/create + { + "chain": "solana", + "set_default": true + } + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + result = await accounts_service.gateway_client.create_wallet( + chain=request.chain, + set_default=request.set_default + ) + + if result is None: + raise HTTPException(status_code=502, detail="Failed to create wallet: Gateway returned no response") + + if "error" in result: + raise HTTPException(status_code=400, detail=f"Failed to create wallet: {result.get('error')}") + + return result + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error creating wallet: {str(e)}") + + +@router.post("/wallets/show-private-key") +async def show_private_key( + request: ShowPrivateKeyRequest, + accounts_service: AccountsService = Depends(get_accounts_service) +) -> Dict: + """ + Show private key for a wallet. + + WARNING: This endpoint exposes sensitive information. Use with caution. + + Args: + request: Contains chain, address, and passphrase + + Returns: + Dict with privateKey field. + + Example: POST /gateway/wallets/show-private-key + { + "chain": "solana", + "address": "", + "passphrase": "" + } + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + result = await accounts_service.gateway_client.show_private_key( + chain=request.chain, + address=request.address, + passphrase=request.passphrase + ) + + if result is None: + raise HTTPException(status_code=502, detail="Failed to retrieve private key: Gateway returned no response") + + if "error" in result: + raise HTTPException(status_code=400, detail=f"Failed to retrieve private key: {result.get('error')}") + + return result + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error retrieving private key: {str(e)}") + + +@router.post("/wallets/send") +async def send_transaction( + request: SendTransactionRequest, + accounts_service: AccountsService = Depends(get_accounts_service) +) -> Dict: + """ + Send a native token transaction. + + Args: + request: Contains chain, network, sender address, recipient address, and amount + + Returns: + Dict with transaction signature/hash. + + Example: POST /gateway/wallets/send + { + "chain": "solana", + "network": "mainnet-beta", + "address": "", + "to_address": "", + "amount": "0.001" + } + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + result = await accounts_service.gateway_client.send_transaction( + chain=request.chain, + network=request.network, + address=request.address, + to_address=request.to_address, + amount=request.amount + ) + + if result is None: + raise HTTPException(status_code=502, detail="Failed to send transaction: Gateway returned no response") + + if "error" in result: + raise HTTPException(status_code=400, detail=f"Failed to send transaction: {result.get('error')}") + + return result + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error sending transaction: {str(e)}") diff --git a/services/bots_orchestrator.py b/services/bots_orchestrator.py index b4449e95..85622f14 100644 --- a/services/bots_orchestrator.py +++ b/services/bots_orchestrator.py @@ -158,7 +158,7 @@ async def stop_bot(self, bot_name, **kwargs): # Clear performance data after stop command to immediately reflect stopped status if success: - self.mqtt_manager.clear_bot_performance(bot_name) + self.mqtt_manager.clear_bot_controller_reports(bot_name) return {"success": success} @@ -221,19 +221,59 @@ async def get_bot_history(self, bot_name, **kwargs): return {"success": True, "data": response} @staticmethod - def determine_controller_performance(controllers_performance): - cleaned_performance = {} - for controller, performance in controllers_performance.items(): + def determine_controller_performance(controller_reports): + """Process controller reports and extract performance and custom_info. + + Args: + controller_reports: Dict with controller_id as key and report dict as value. + New format: Each report contains 'performance' and 'custom_info' keys. + Old format: Report contains performance metrics directly (backward compatible). + + Returns: + Dict with cleaned controller data including status, performance, and custom_info. + """ + cleaned_data = {} + for controller_id, report in controller_reports.items(): try: - # Check if all the metrics are numeric - _ = sum(metric for key, metric in performance.items() if key not in ("positions_summary", "close_type_counts")) - cleaned_performance[controller] = {"status": "running", "performance": performance} + # Support both new format (nested) and old format (flat) + # New format: {"performance": {...}, "custom_info": {...}} + # Old format: {...performance metrics directly...} + if "performance" in report: + # New format with nested structure + performance = report.get("performance", {}) + custom_info = report.get("custom_info", {}) + else: + # Old format - metrics are directly in the report + performance = report + custom_info = {} + + # Validate performance metrics are numeric (skip known non-numeric fields) + non_numeric_fields = ("positions_summary", "close_type_counts") + _ = sum( + metric for key, metric in performance.items() + if key not in non_numeric_fields and isinstance(metric, (int, float)) + ) + + cleaned_data[controller_id] = { + "status": "running", + "performance": performance, + "custom_info": custom_info + } except Exception as e: - cleaned_performance[controller] = { + # Handle both formats in error case too + if "performance" in report: + perf = report.get("performance", {}) + info = report.get("custom_info", {}) + else: + perf = report + info = {} + cleaned_data[controller_id] = { "status": "error", - "error": f"Some metrics are not numeric, check logs and restart controller: {e}", + "error": f"Error processing controller data: {e}", + "performance": perf, + "custom_info": info } - return cleaned_performance + return cleaned_data def get_all_bots_status(self): # TODO: improve logic of bots state management @@ -265,8 +305,8 @@ def get_bot_status(self, bot_name): } # Get data from MQTT manager - controllers_performance = self.mqtt_manager.get_bot_performance(bot_name) - performance = self.determine_controller_performance(controllers_performance) + controller_reports = self.mqtt_manager.get_bot_controller_reports(bot_name) + performance = self.determine_controller_performance(controller_reports) error_logs = self.mqtt_manager.get_bot_error_logs(bot_name) general_logs = self.mqtt_manager.get_bot_logs(bot_name) diff --git a/services/gateway_client.py b/services/gateway_client.py index f11feb51..0c43687a 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -163,6 +163,38 @@ async def add_wallet(self, chain: str, private_key: str, set_default: bool = Tru "setDefault": set_default }) + async def create_wallet(self, chain: str, set_default: bool = True) -> Dict: + """Create a new wallet in Gateway""" + return await self._request("POST", "wallet/create", json={ + "chain": chain, + "setDefault": set_default + }) + + async def show_private_key(self, chain: str, address: str, passphrase: str) -> Dict: + """Show private key for a wallet""" + return await self._request("POST", "wallet/show-private-key", json={ + "chain": chain, + "address": address, + "passphrase": passphrase + }) + + async def send_transaction( + self, + chain: str, + network: str, + address: str, + to_address: str, + amount: str + ) -> Dict: + """Send a native token transaction""" + return await self._request("POST", "wallet/send", json={ + "chain": chain, + "network": network, + "address": address, + "toAddress": to_address, + "amount": amount + }) + async def remove_wallet(self, chain: str, address: str) -> Dict: """Remove a wallet from Gateway""" return await self._request("DELETE", "wallet/remove", json={ diff --git a/utils/mqtt_manager.py b/utils/mqtt_manager.py index e2a1473d..3495eadb 100644 --- a/utils/mqtt_manager.py +++ b/utils/mqtt_manager.py @@ -26,8 +26,8 @@ def __init__(self, host: str, port: int, username: str, password: str): # Message handlers by topic pattern self._handlers: Dict[str, Callable] = {} - # Bot data storage - self._bot_performance: Dict[str, Dict] = defaultdict(dict) + # Bot data storage - stores full controller reports (performance + custom_info) + self._bot_controller_reports: Dict[str, Dict] = defaultdict(dict) self._bot_logs: Dict[str, deque] = defaultdict(lambda: deque(maxlen=100)) self._bot_error_logs: Dict[str, deque] = defaultdict(lambda: deque(maxlen=100)) @@ -182,12 +182,21 @@ def _match_topic(self, pattern: str, topic: str) -> bool: return True async def _handle_performance(self, bot_id: str, data: Any): - """Handle performance updates.""" + """Handle performance updates. + + Expected data structure from Hummingbot: + { + "controller_id": { + "performance": { ... performance metrics ... }, + "custom_info": { ... custom controller data ... } + } + } + """ if isinstance(data, dict): - for controller_id, performance in data.items(): - if bot_id not in self._bot_performance: - self._bot_performance[bot_id] = {} - self._bot_performance[bot_id][controller_id] = performance + for controller_id, controller_report in data.items(): + if bot_id not in self._bot_controller_reports: + self._bot_controller_reports[bot_id] = {} + self._bot_controller_reports[bot_id][controller_id] = controller_report async def _handle_log(self, bot_id: str, data: Any): """Handle log messages with deduplication.""" @@ -468,9 +477,14 @@ def remove_handler(self, topic_pattern: str): """Remove a message handler.""" self._handlers.pop(topic_pattern, None) - def get_bot_performance(self, bot_id: str) -> Dict[str, Any]: - """Get performance data for a bot.""" - return self._bot_performance.get(bot_id, {}) + def get_bot_controller_reports(self, bot_id: str) -> Dict[str, Any]: + """Get controller reports for a bot. + + Returns: + Dict with controller_id as key and report dict as value. + Each report contains 'performance' and 'custom_info' keys. + """ + return self._bot_controller_reports.get(bot_id, {}) def get_bot_logs(self, bot_id: str) -> list: """Get recent logs for a bot.""" @@ -482,14 +496,14 @@ def get_bot_error_logs(self, bot_id: str) -> list: def clear_bot_data(self, bot_id: str): """Clear stored data for a bot.""" - self._bot_performance.pop(bot_id, None) + self._bot_controller_reports.pop(bot_id, None) self._bot_logs.pop(bot_id, None) self._bot_error_logs.pop(bot_id, None) self._discovered_bots.pop(bot_id, None) - def clear_bot_performance(self, bot_id: str): - """Clear only performance data for a bot (useful when bot is stopped).""" - self._bot_performance.pop(bot_id, None) + def clear_bot_controller_reports(self, bot_id: str): + """Clear only controller report data for a bot (useful when bot is stopped).""" + self._bot_controller_reports.pop(bot_id, None) @property def is_connected(self) -> bool: