From b9db532263f3c89f2a0bebff8e7079a09e378a49 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 11 Mar 2026 19:42:23 -0300 Subject: [PATCH 01/34] Add SwapExecutor support - Register SwapExecutor in EXECUTOR_REGISTRY - Add swap_executor to available executor types Requires: hummingbot/hummingbot#8117 Co-Authored-By: Claude Opus 4.5 --- routers/executors.py | 5 +++++ services/executor_service.py | 3 +++ 2 files changed, 8 insertions(+) diff --git a/routers/executors.py b/routers/executors.py index 8cb8545c..2b56fb99 100644 --- a/routers/executors.py +++ b/routers/executors.py @@ -241,6 +241,11 @@ async def get_available_executor_types(): "type": "lp_executor", "description": "LP position management for CLMM pools (Meteora, Raydium) ", "use_case": "Automated liquidity provision with position tracking" + }, + { + "type": "swap_executor", + "description": "Single swap execution on Gateway AMM connectors", + "use_case": "Executing swaps on DEXs like Jupiter with retry logic" } ] } diff --git a/services/executor_service.py b/services/executor_service.py index 13b24dc1..aaec2757 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -26,6 +26,8 @@ from hummingbot.strategy_v2.executors.order_executor.order_executor import OrderExecutor from hummingbot.strategy_v2.executors.position_executor.data_types import PositionExecutorConfig from hummingbot.strategy_v2.executors.position_executor.position_executor import PositionExecutor +from hummingbot.strategy_v2.executors.swap_executor.data_types import SwapExecutorConfig +from hummingbot.strategy_v2.executors.swap_executor.swap_executor import SwapExecutor from hummingbot.strategy_v2.executors.twap_executor.data_types import TWAPExecutorConfig from hummingbot.strategy_v2.executors.twap_executor.twap_executor import TWAPExecutor from hummingbot.strategy_v2.executors.xemm_executor.data_types import XEMMExecutorConfig @@ -82,6 +84,7 @@ class ExecutorService: "xemm_executor": (XEMMExecutor, XEMMExecutorConfig), "order_executor": (OrderExecutor, OrderExecutorConfig), "lp_executor": (LPExecutor, LPExecutorConfig), + "swap_executor": (SwapExecutor, SwapExecutorConfig), } def __init__( From f9f203dde0486ddd172ed7b943468e0126707215 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 12 Mar 2026 16:55:43 -0300 Subject: [PATCH 02/34] fix: remove misleading restart message for token add/delete Tokens are available immediately after adding - no Gateway restart needed. Co-Authored-By: Claude Opus 4.5 --- routers/gateway.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/routers/gateway.py b/routers/gateway.py index 8bca7309..6c599b87 100644 --- a/routers/gateway.py +++ b/routers/gateway.py @@ -1,19 +1,20 @@ -from fastapi import APIRouter, HTTPException, Depends, Query -from typing import Optional, Dict, List import re +from typing import Dict, List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query + +from deps import get_accounts_service, get_gateway_service from models import ( - GatewayConfig, - GatewayStatus, AddPoolRequest, AddTokenRequest, CreateWalletRequest, - ShowPrivateKeyRequest, + GatewayConfig, + GatewayStatus, SendTransactionRequest, + ShowPrivateKeyRequest, ) -from services.gateway_service import GatewayService from services.accounts_service import AccountsService -from deps import get_gateway_service, get_accounts_service +from services.gateway_service import GatewayService router = APIRouter(tags=["Gateway"], prefix="/gateway") @@ -605,9 +606,7 @@ async def add_network_token( return { "success": True, - "message": f"Token {token_request.symbol} added to {network_id}. Restart Gateway for changes to take effect.", - "restart_required": True, - "restart_endpoint": "POST /gateway/restart", + "message": f"Token {token_request.symbol} added to {network_id}.", "token": { "symbol": token_request.symbol, "address": token_request.address, @@ -660,9 +659,7 @@ async def delete_network_token( return { "success": True, - "message": f"Token {token_address} deleted from {network_id}. Restart Gateway for changes to take effect.", - "restart_required": True, - "restart_endpoint": "POST /gateway/restart", + "message": f"Token {token_address} deleted from {network_id}.", "token_address": token_address, "network_id": network_id } From 02ef8ea8e659c0d0d59128bc72a5a36bf516b061 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 27 Mar 2026 18:35:01 -0700 Subject: [PATCH 03/34] Add /lphistory endpoint for LP position history - Add get_bot_lp_history() method to BotsOrchestrator - Add GET /{bot_name}/lphistory endpoint to bot_orchestration router - Add "lphistory" to known MQTT command channels Returns LP-specific data: position_address, order_action, fees collected, price ranges, and amounts for AMM/CLMM strategies like Meteora. Fixes #132 Co-Authored-By: Claude Opus 4.5 --- .../master_account/conf_client.yml | 3 +- routers/bot_orchestration.py | 45 +++++++++++++++++++ services/bots_orchestrator.py | 32 ++++++++++++- utils/mqtt_manager.py | 2 +- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/bots/credentials/master_account/conf_client.yml b/bots/credentials/master_account/conf_client.yml index cbba78d4..21e00ebd 100644 --- a/bots/credentials/master_account/conf_client.yml +++ b/bots/credentials/master_account/conf_client.yml @@ -112,7 +112,7 @@ anonymized_metrics_mode: # A source for rate oracle, currently ascend_ex, binance, coin_gecko, coin_cap, kucoin, gate_io rate_oracle_source: - name: binance + name: gate_io # A universal token which to display tokens values in, e.g. USD,EUR,BTC global_token: @@ -133,7 +133,6 @@ tables_format: psql paper_trade: paper_trade_exchanges: - - binance - kucoin - ascend_ex - gate_io diff --git a/routers/bot_orchestration.py b/routers/bot_orchestration.py index 8224c25d..994fe60f 100644 --- a/routers/bot_orchestration.py +++ b/routers/bot_orchestration.py @@ -122,6 +122,51 @@ async def get_bot_history( return {"status": "success", "response": response} +@router.get("/{bot_name}/lphistory") +async def get_bot_lp_history( + bot_name: str, + days: int = 0, + verbose: bool = False, + precision: int = None, + timeout: float = 30.0, + bots_manager: BotsOrchestrator = Depends(get_bots_orchestrator) +): + """ + Get LP (liquidity provider) position history for a bot. + + This endpoint returns LP-specific data including position updates, + fees collected, and liquidity additions/removals. Use this for + AMM/CLMM strategies like Meteora. + + Args: + bot_name: Name of the bot to get LP history for + days: Number of days of history to retrieve (0 for all) + verbose: Whether to include verbose output + precision: Decimal precision for numerical values + timeout: Timeout in seconds for the operation + bots_manager: Bot orchestrator service dependency + + Returns: + Dictionary with LP position history including: + - position_address: The LP position address + - order_action: ADD or REMOVE + - trading_pair: The trading pair (e.g., SOL-USDC) + - base_amount, quote_amount: Amounts added/removed + - base_fee, quote_fee: Fees collected + - lower_price, upper_price: Price range of position + - mid_price: Price at time of operation + - trade_fee: Transaction fees paid + """ + response = await bots_manager.get_bot_lp_history( + bot_name, + days=days, + verbose=verbose, + precision=precision, + timeout=timeout + ) + return {"status": "success", "response": response} + + @router.post("/start-bot") async def start_bot( action: StartBotAction, diff --git a/services/bots_orchestrator.py b/services/bots_orchestrator.py index 85622f14..887700af 100644 --- a/services/bots_orchestrator.py +++ b/services/bots_orchestrator.py @@ -1,7 +1,7 @@ import asyncio import logging -from typing import Optional import re +from typing import Optional import docker @@ -220,6 +220,36 @@ async def get_bot_history(self, bot_name, **kwargs): return {"success": True, "data": response} + async def get_bot_lp_history(self, bot_name, **kwargs): + """ + Request bot LP (liquidity provider) history and wait for the response. + This returns LP position updates from RangePositionUpdate records. + """ + if bot_name not in self.active_bots: + logger.warning(f"Bot {bot_name} not found in active bots") + return {"success": False, "message": f"Bot {bot_name} not found"} + + # Create LPHistoryCommandMessage.Request format + data = { + "days": kwargs.get("days", 0), + "verbose": kwargs.get("verbose", False), + "precision": kwargs.get("precision"), + "async_backend": kwargs.get("async_backend", False), + } + + # Use the new RPC method to wait for response + timeout = kwargs.get("timeout", 30.0) # Default 30 second timeout + response = await self.mqtt_manager.publish_command_and_wait(bot_name, "lphistory", data, timeout=timeout) + + if response is None: + return { + "success": False, + "message": f"No response received from {bot_name} within {timeout} seconds", + "timeout": True, + } + + return {"success": True, "data": response} + @staticmethod def determine_controller_performance(controller_reports): """Process controller reports and extract performance and custom_info. diff --git a/utils/mqtt_manager.py b/utils/mqtt_manager.py index 3495eadb..63286c87 100644 --- a/utils/mqtt_manager.py +++ b/utils/mqtt_manager.py @@ -151,7 +151,7 @@ async def _process_message(self, message): await self._handle_command_response(bot_id, channel, data) elif channel.startswith("external/event/"): await self._handle_external_event(bot_id, channel, data) - elif channel in ["history", "start", "stop", "config", "import_strategy"]: + elif channel in ["history", "lphistory", "start", "stop", "config", "import_strategy"]: # These are command channels - responses should come on response/* topics logger.debug(f"Command channel '{channel}' for bot {bot_id} - waiting for response") else: From 28b15caffaef6c4dc7231ae802f81fc64255a688 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Sat, 28 Mar 2026 14:38:56 -0700 Subject: [PATCH 04/34] Add swap_executor type and improve executor handling - Add swap_executor to EXECUTOR_TYPES for single swaps via Gateway - Add swap_executor example in CreateExecutorRequest - Handle swap_executor using network instead of connector_name - Make trading_pair optional for lp_executor (resolved from pool_address) - Update lp_executor example with simplified config Co-Authored-By: Claude Opus 4.5 --- models/executors.py | 25 +++++++++++++++++++++---- services/executor_service.py | 35 +++++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/models/executors.py b/models/executors.py index dda1b455..d996017f 100644 --- a/models/executors.py +++ b/models/executors.py @@ -211,7 +211,8 @@ class PositionsSummaryResponse(BaseModel): "twap_executor", "xemm_executor", "order_executor", - "lp_executor" + "lp_executor", + "swap_executor" ] @@ -246,14 +247,14 @@ class CreateExecutorRequest(BaseModel): }, { "summary": "LP Executor", - "description": "Create an LP position on a CLMM DEX (Meteora, Raydium)", + "description": "Create an LP position on a CLMM DEX", "value": { "account_name": "master_account", "executor_config": { "type": "lp_executor", - "connector_name": "meteora/clmm", - "trading_pair": "SOL-USDC", + "connector_name": "meteora", "pool_address": "HTvjzsfX3yU6BUodCjZ5vZkUrAxMDTrBs3CJaq43ashR", + "network": "solana-mainnet-beta", "lower_price": "80", "upper_price": "100", "base_amount": "0", @@ -265,6 +266,22 @@ class CreateExecutorRequest(BaseModel): "keep_position": False } } + }, + { + "summary": "Swap Executor", + "description": "Execute a single swap on Gateway AMM connectors (Jupiter, Raydium, etc.)", + "value": { + "account_name": "master_account", + "executor_config": { + "type": "swap_executor", + "network": "solana-mainnet-beta", + "trading_pair": "SOL-USDC", + "side": 2, + "amount": "0.1", + "slippage_pct": "0.5", + "swap_providers": ["jupiter/router", "meteora/clmm", "orca/clmm"] + } + } } ] } diff --git a/services/executor_service.py b/services/executor_service.py index aaec2757..63c0dca1 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -349,15 +349,38 @@ async def create_executor( trading_interface = self._get_trading_interface(account) # Extract connector and trading pair from config + # Note: swap_executor uses 'network' instead of 'connector_name' since it calls Gateway directly connector_name = executor_config.get("connector_name") trading_pair = executor_config.get("trading_pair") - if not connector_name: - raise HTTPException(status_code=400, detail="connector_name is required in executor_config") - if not trading_pair: - raise HTTPException(status_code=400, detail="trading_pair is required in executor_config") - # Ensure connector and market are ready - await trading_interface.add_market(connector_name, trading_pair) + if executor_type == "swap_executor": + # SwapExecutor uses network, not connector_name + network = executor_config.get("network") + if not network: + raise HTTPException(status_code=400, detail="network is required for swap_executor") + if not trading_pair: + raise HTTPException(status_code=400, detail="trading_pair is required in executor_config") + # Use network as connector_name for metadata tracking + connector_name = network + elif executor_type == "lp_executor": + # LPExecutor: trading_pair is optional (resolved from pool_address) + if not connector_name: + raise HTTPException(status_code=400, detail="connector_name is required for lp_executor") + pool_address = executor_config.get("pool_address") + if not pool_address: + raise HTTPException(status_code=400, detail="pool_address is required for lp_executor") + # Ensure connector is ready (trading_pair resolved in executor on_start) + await trading_interface.ensure_connector(connector_name) + # Use pool_address as trading_pair placeholder for metadata if not provided + if not trading_pair: + trading_pair = pool_address + else: + if not connector_name: + raise HTTPException(status_code=400, detail="connector_name is required in executor_config") + if not trading_pair: + raise HTTPException(status_code=400, detail="trading_pair is required in executor_config") + # Ensure connector and market are ready + await trading_interface.add_market(connector_name, trading_pair) # Set timestamp if not provided (required for time-based features like time_limit) if "timestamp" not in executor_config or executor_config["timestamp"] is None: From 2d730833b4f82cd992a8b4625a8222a60b23eb70 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Sat, 28 Mar 2026 20:34:46 -0700 Subject: [PATCH 05/34] Add GatewaySwap connector support for swap executor - Create GatewaySwap connector for router-type connectors (e.g., jupiter/router) - Update executor_service to ensure swap connector is loaded before execution - Add database access documentation for debugging executors Co-Authored-By: Claude Opus 4.5 --- services/executor_service.py | 11 +++++++--- services/unified_connector_service.py | 29 ++++++++++++++++++--------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/services/executor_service.py b/services/executor_service.py index 63c0dca1..0c227ee3 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -354,14 +354,19 @@ async def create_executor( trading_pair = executor_config.get("trading_pair") if executor_type == "swap_executor": - # SwapExecutor uses network, not connector_name + # SwapExecutor requires connector_name (e.g., "jupiter/router") and network + swap_connector_name = executor_config.get("connector_name") network = executor_config.get("network") + if not swap_connector_name: + raise HTTPException(status_code=400, detail="connector_name is required for swap_executor") if not network: raise HTTPException(status_code=400, detail="network is required for swap_executor") if not trading_pair: raise HTTPException(status_code=400, detail="trading_pair is required in executor_config") - # Use network as connector_name for metadata tracking - connector_name = network + # Ensure the swap connector is loaded + await trading_interface.ensure_connector(swap_connector_name) + # Use connector_name for metadata tracking + connector_name = swap_connector_name elif executor_type == "lp_executor": # LPExecutor: trading_pair is optional (resolved from pool_address) if not connector_name: diff --git a/services/unified_connector_service.py b/services/unified_connector_service.py index 9d697f0a..eb3735e6 100644 --- a/services/unified_connector_service.py +++ b/services/unified_connector_service.py @@ -23,6 +23,7 @@ from hummingbot.connector.connector_metrics_collector import TradeVolumeMetricCollector from hummingbot.connector.exchange_py_base import ExchangePyBase from hummingbot.connector.gateway.gateway_lp import GatewayLp +from hummingbot.connector.gateway.gateway_swap import GatewaySwap from hummingbot.connector.perpetual_derivative_py_base import PerpetualDerivativePyBase from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, TradeType from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState @@ -643,17 +644,25 @@ def _create_trading_connector( secrets_manager=self.secrets_manager ) - # Gateway connectors (e.g., 'meteora/clmm', 'raydium/clmm') are not in AllConnectorSettings - # They use GatewayLp which auto-detects chain/network from gateway config + # Gateway connectors (e.g., 'meteora/clmm', 'jupiter/router') are not in AllConnectorSettings + # Router connectors use GatewaySwap, LP connectors use GatewayLp + # Both auto-detect chain/network from gateway config if '/' in connector_name: - logger.info(f"Creating gateway connector: {connector_name}") - # GatewayLp handles chain/network auto-detection and default wallet lookup - # via start_network() call - return GatewayLp( - connector_name=connector_name, - trading_pairs=[], - trading_required=True, - ) + _, connector_type = connector_name.split('/', 1) + if connector_type == 'router': + logger.info(f"Creating gateway swap connector: {connector_name}") + return GatewaySwap( + connector_name=connector_name, + trading_pairs=[], + trading_required=True, + ) + else: + logger.info(f"Creating gateway LP connector: {connector_name}") + return GatewayLp( + connector_name=connector_name, + trading_pairs=[], + trading_required=True, + ) conn_setting = self._conn_settings[connector_name] keys = BackendAPISecurity.api_keys(connector_name) From 51f48decd5a5a7a743aab16c14873d95a14ee78c Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Sat, 28 Mar 2026 21:04:29 -0700 Subject: [PATCH 06/34] Revert conf_client.yml to default settings This file contains user-specific config and shouldn't be modified in the PR. Co-Authored-By: Claude Opus 4.5 --- bots/credentials/master_account/conf_client.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bots/credentials/master_account/conf_client.yml b/bots/credentials/master_account/conf_client.yml index 21e00ebd..cbba78d4 100644 --- a/bots/credentials/master_account/conf_client.yml +++ b/bots/credentials/master_account/conf_client.yml @@ -112,7 +112,7 @@ anonymized_metrics_mode: # A source for rate oracle, currently ascend_ex, binance, coin_gecko, coin_cap, kucoin, gate_io rate_oracle_source: - name: gate_io + name: binance # A universal token which to display tokens values in, e.g. USD,EUR,BTC global_token: @@ -133,6 +133,7 @@ tables_format: psql paper_trade: paper_trade_exchanges: + - binance - kucoin - ascend_ex - gate_io From f5216e3babff1f1fc96a33693cb18392a1d4075b Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Sat, 28 Mar 2026 22:01:00 -0700 Subject: [PATCH 07/34] Update Gateway poll integration for simplified endpoint - Remove wallet_address param from poll_transaction (Gateway PR #620) - Handle new txStatus=-1 for failed transactions - Use Gateway's parsed error messages (e.g., "SLIPPAGE_EXCEEDED (0x1771): ...") - Fallback to meta.err if parsed error not available Co-Authored-By: Claude Opus 4.5 --- services/gateway_client.py | 9 ++------- services/gateway_transaction_poller.py | 27 ++++++++++++++------------ 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/services/gateway_client.py b/services/gateway_client.py index b62aec07..9a92a19b 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -1,5 +1,4 @@ import logging -from decimal import Decimal from typing import Dict, List, Optional import aiohttp @@ -582,7 +581,6 @@ async def poll_transaction( self, network_id: str, tx_hash: str, - wallet_address: Optional[str] = None ) -> Optional[Dict]: """ Poll transaction status on blockchain. @@ -590,12 +588,12 @@ async def poll_transaction( Args: network_id: Network ID in format 'chain-network' (e.g., 'solana-mainnet-beta', 'ethereum-mainnet') tx_hash: Transaction hash/signature - wallet_address: Optional wallet address for verification Returns: Transaction status dict with fields: - - txStatus: 1 for confirmed, 0 for failed/pending + - txStatus: 1 for confirmed, 0 for pending, -1 for failed - fee: Transaction fee amount + - error: Parsed error message if transaction failed (e.g., "SLIPPAGE_EXCEEDED (0x1771): ...") - txData: Full transaction data including meta.err Returns None if Gateway is unavailable or request fails. """ @@ -612,11 +610,8 @@ async def poll_transaction( "network": network, "signature": tx_hash } - if wallet_address: - payload["walletAddress"] = wallet_address return await self._request("POST", f"chains/{chain}/poll", json=payload) except Exception as e: logger.error(f"Error polling transaction {tx_hash}: {e}") return None - diff --git a/services/gateway_transaction_poller.py b/services/gateway_transaction_poller.py index 991fa44d..33d810f4 100644 --- a/services/gateway_transaction_poller.py +++ b/services/gateway_transaction_poller.py @@ -8,16 +8,16 @@ """ import asyncio import logging -from typing import Optional, Dict, List from datetime import datetime, timedelta, timezone from decimal import Decimal +from typing import Dict, List, Optional from sqlalchemy import select from sqlalchemy.orm import selectinload from database import AsyncDatabaseManager -from database.repositories import GatewaySwapRepository, GatewayCLMMRepository from database.models import GatewayCLMMEvent, GatewayCLMMPosition +from database.repositories import GatewayCLMMRepository, GatewaySwapRepository from services.gateway_client import GatewayClient logger = logging.getLogger(__name__) @@ -312,9 +312,6 @@ async def _check_transaction_status( # Parse the response with defensive checks tx_status = result.get("txStatus") - tx_data = result.get("txData") or {} - meta = tx_data.get("meta") if isinstance(tx_data, dict) else {} - error = meta.get("err") if isinstance(meta, dict) else None # Determine gas token based on chain gas_token = { @@ -326,8 +323,8 @@ async def _check_transaction_status( "avalanche": "AVAX" }.get(chain, "UNKNOWN") - # Transaction is confirmed if txStatus == 1 and no error - if tx_status == 1 and error is None: + # Transaction is confirmed if txStatus == 1 + if tx_status == 1: return { "status": "CONFIRMED", "gas_fee": result.get("fee", 0), @@ -335,9 +332,16 @@ async def _check_transaction_status( "error_message": None } - # Transaction failed if there's an error - if error is not None: - error_msg = str(error) if error else "Transaction failed on-chain" + # Transaction failed if txStatus == -1 or there's an error field + # Gateway now returns parsed error messages like "SLIPPAGE_EXCEEDED (0x1771): ..." + error_msg = result.get("error") + if tx_status == -1 or error_msg: + if not error_msg: + # Fallback to meta.err if no parsed error + tx_data = result.get("txData") or {} + meta = tx_data.get("meta") if isinstance(tx_data, dict) else {} + raw_error = meta.get("err") if isinstance(meta, dict) else None + error_msg = str(raw_error) if raw_error else "Transaction failed on-chain" return { "status": "FAILED", "gas_fee": result.get("fee", 0), @@ -352,14 +356,13 @@ async def _check_transaction_status( logger.error(f"Error checking transaction status for {tx_hash}: {e}") return None - async def poll_transaction_once(self, tx_hash: str, network_id: str, wallet_address: Optional[str] = None) -> Optional[Dict]: + async def poll_transaction_once(self, tx_hash: str, network_id: str) -> Optional[Dict]: """ Poll a specific transaction once (useful for immediate status checks). Args: tx_hash: Transaction hash network_id: Network ID in format 'chain-network' (e.g., 'solana-mainnet-beta') - wallet_address: Optional wallet address for verification Returns: Transaction status dict or None if pending From 6aafc3ba6a7fc5c85c02129091b088bd24e48671 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Sat, 28 Mar 2026 22:06:26 -0700 Subject: [PATCH 08/34] Fix swap_executor example: add required connector_name The executor_service validates that connector_name is required for swap_executor, but the example in models/executors.py was missing it. Co-Authored-By: Claude Opus 4.5 --- models/executors.py | 1 + 1 file changed, 1 insertion(+) diff --git a/models/executors.py b/models/executors.py index d996017f..0194d70e 100644 --- a/models/executors.py +++ b/models/executors.py @@ -274,6 +274,7 @@ class CreateExecutorRequest(BaseModel): "account_name": "master_account", "executor_config": { "type": "swap_executor", + "connector_name": "jupiter/router", "network": "solana-mainnet-beta", "trading_pair": "SOL-USDC", "side": 2, From 42834483965270c3115c7ca8aad7581325c55342 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 1 Apr 2026 01:01:15 -0700 Subject: [PATCH 09/34] Simplify Gateway executor validation in executor_service - Consolidate swap_executor and lp_executor validation into single block - Network is optional - uses connector's defaultNetwork if not provided - Executor handles connector normalization in on_start Co-Authored-By: Claude Opus 4.5 --- services/executor_service.py | 44 ++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/services/executor_service.py b/services/executor_service.py index 0c227ee3..4813c573 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -349,37 +349,31 @@ async def create_executor( trading_interface = self._get_trading_interface(account) # Extract connector and trading pair from config - # Note: swap_executor uses 'network' instead of 'connector_name' since it calls Gateway directly connector_name = executor_config.get("connector_name") trading_pair = executor_config.get("trading_pair") - if executor_type == "swap_executor": - # SwapExecutor requires connector_name (e.g., "jupiter/router") and network - swap_connector_name = executor_config.get("connector_name") - network = executor_config.get("network") - if not swap_connector_name: - raise HTTPException(status_code=400, detail="connector_name is required for swap_executor") - if not network: - raise HTTPException(status_code=400, detail="network is required for swap_executor") - if not trading_pair: - raise HTTPException(status_code=400, detail="trading_pair is required in executor_config") - # Ensure the swap connector is loaded - await trading_interface.ensure_connector(swap_connector_name) - # Use connector_name for metadata tracking - connector_name = swap_connector_name - elif executor_type == "lp_executor": - # LPExecutor: trading_pair is optional (resolved from pool_address) + if executor_type in ("swap_executor", "lp_executor"): + # Gateway executors: connector_name required, trading_pair optional + # Network is optional - uses connector's defaultNetwork if not provided if not connector_name: - raise HTTPException(status_code=400, detail="connector_name is required for lp_executor") - pool_address = executor_config.get("pool_address") - if not pool_address: - raise HTTPException(status_code=400, detail="pool_address is required for lp_executor") - # Ensure connector is ready (trading_pair resolved in executor on_start) + raise HTTPException(status_code=400, detail=f"connector_name is required for {executor_type}") + + if executor_type == "lp_executor": + pool_address = executor_config.get("pool_address") + if not pool_address: + raise HTTPException(status_code=400, detail="pool_address is required for lp_executor") + # Use pool_address as trading_pair placeholder for metadata if not provided + if not trading_pair: + trading_pair = pool_address + else: + # swap_executor requires trading_pair + if not trading_pair: + raise HTTPException(status_code=400, detail="trading_pair is required for swap_executor") + + # Ensure connector is ready (executor handles connector normalization in on_start) await trading_interface.ensure_connector(connector_name) - # Use pool_address as trading_pair placeholder for metadata if not provided - if not trading_pair: - trading_pair = pool_address else: + # Standard executors: both connector_name and trading_pair required if not connector_name: raise HTTPException(status_code=400, detail="connector_name is required in executor_config") if not trading_pair: From ec0670852232baaae615beae342285456324ffca Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 1 Apr 2026 13:41:02 -0700 Subject: [PATCH 10/34] sync: update lp_rebalancer from hummingbot - Add autoswap feature for automatic token swapping when balance insufficient - Fix _initial_position_created flag to only set when position is active - Add negative offset support for in-range positions - Add swap_provider and swap_buffer_pct config options Co-Authored-By: Claude Opus 4.5 --- .../generic/lp_rebalancer/lp_rebalancer.py | 588 ++++++++++++++---- 1 file changed, 467 insertions(+), 121 deletions(-) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index 6c400629..dcee3394 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -2,13 +2,14 @@ from decimal import Decimal from typing import List, Optional -from hummingbot.core.data_type.common import MarketDict +from hummingbot.core.data_type.common import MarketDict, TradeType from hummingbot.core.utils.async_utils import safe_ensure_future from hummingbot.data_feed.candles_feed.data_types import CandlesConfig from hummingbot.logger import HummingbotLogger from hummingbot.strategy_v2.controllers import ControllerBase, ControllerConfigBase from hummingbot.strategy_v2.executors.data_types import ConnectorPair from hummingbot.strategy_v2.executors.lp_executor.data_types import LPExecutorConfig, LPExecutorStates +from hummingbot.strategy_v2.executors.swap_executor.data_types import SwapExecutorConfig, SwapExecutorStates from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction, StopExecutorAction from hummingbot.strategy_v2.models.executors_info import ExecutorInfo from pydantic import Field, field_validator, model_validator @@ -38,7 +39,8 @@ class LPRebalancerConfig(ControllerConfigBase): position_offset_pct: Decimal = Field( default=Decimal("0.01"), json_schema_extra={"is_updatable": True}, - description="Offset from current price to ensure single-sided positions start out-of-range (e.g., 0.1 = 0.1%)" + description="Offset from current price. Positive = out-of-range (single-sided). " + "Negative = in-range (needs both tokens, autoswap will convert |offset|%)" ) # Rebalancing @@ -60,6 +62,23 @@ class LPRebalancerConfig(ControllerConfigBase): # Connector-specific params (optional) strategy_type: Optional[int] = Field(default=None, json_schema_extra={"is_updatable": True}) + # Auto-swap feature: swap tokens if balance insufficient for position + autoswap: bool = Field( + default=False, + json_schema_extra={"is_updatable": True}, + description="Automatically swap tokens if balance is insufficient for position" + ) + swap_buffer_pct: Decimal = Field( + default=Decimal("0.01"), + json_schema_extra={"is_updatable": True}, + description="Extra % to swap beyond deficit to account for slippage (e.g., 0.01 = 0.01%)" + ) + swap_provider: Optional[str] = Field( + default=None, + json_schema_extra={"is_updatable": False}, + description="Swap provider for autoswap (e.g., 'jupiter/router'). Required if autoswap=True." + ) + @field_validator("sell_price_min", "sell_price_max", "buy_price_min", "buy_price_max", mode="before") @classmethod def validate_price_limits(cls, v): @@ -86,11 +105,25 @@ def validate_price_limit_ranges(self): if self.sell_price_max is not None and self.sell_price_min is not None: if self.sell_price_max < self.sell_price_min: raise ValueError("sell_price_max must be >= sell_price_min") + # For negative offset (in-range), offset magnitude must not exceed width + if self.position_offset_pct < 0: + if abs(self.position_offset_pct) > self.position_width_pct: + raise ValueError( + f"For in-range positions, |position_offset_pct| ({abs(self.position_offset_pct)}) " + f"must not exceed position_width_pct ({self.position_width_pct})" + ) + # swap_provider is required when autoswap is enabled + if self.autoswap and not self.swap_provider: + raise ValueError("swap_provider is required when autoswap=True") return self def update_markets(self, markets: MarketDict) -> MarketDict: - """Register the LP connector with trading pair""" - return markets.add_or_update(self.connector_name, self.trading_pair) + """Register the LP connector and swap provider with trading pair""" + markets = markets.add_or_update(self.connector_name, self.trading_pair) + # Also register swap provider if autoswap is enabled + if self.autoswap and self.swap_provider: + markets = markets.add_or_update(self.swap_provider, self.trading_pair) + return markets class LPRebalancer(ControllerBase): @@ -144,6 +177,13 @@ def __init__(self, config: LPRebalancerConfig, *args, **kwargs): # Cached pool price (updated in update_processed_data) self._pool_price: Optional[Decimal] = None + # Swap executor tracking (for autoswap feature) + self._swap_executor_id: Optional[str] = None + self._pending_swap_side: Optional[int] = None # LP side to create after swap completes + + # Track if initial position has been created (after that, always use side 1 or 2) + self._initial_position_created: bool = False + # Initialize rate sources self.market_data_provider.initialize_rate_sources([ ConnectorPair( @@ -176,6 +216,157 @@ def is_tracked_executor_terminated(self) -> bool: return True return executor.status == RunnableStatus.TERMINATED + def get_swap_executor(self) -> Optional[ExecutorInfo]: + """Get the swap executor we're tracking""" + if not self._swap_executor_id: + return None + for e in self.executors_info: + if e.id == self._swap_executor_id: + return e + return None + + def is_swap_executor_done(self) -> bool: + """Check if swap executor has completed (success or failure)""" + if not self._swap_executor_id: + return True + swap_executor = self.get_swap_executor() + if swap_executor is None: + return True + state = swap_executor.custom_info.get("state") + return state in (SwapExecutorStates.COMPLETED.value, SwapExecutorStates.FAILED.value) + + def _check_autoswap_needed(self, side: int, current_price: Decimal) -> Optional[SwapExecutorConfig]: + """ + Check if autoswap is needed and return swap config if so. + + Returns SwapExecutorConfig if swap is needed, None otherwise. + + Simply checks balance vs required amounts and swaps deficit + buffer if insufficient. + Works for both positive offset (out-of-range) and negative offset (in-range) positions. + + For rebalances, includes tokens from just-closed position in available balance + since wallet balance may not be updated yet. + """ + if not self.config.autoswap: + return None + + # Capture closed position amounts BEFORE creating LP position + # (they get cleared after position creation in determine_executor_actions) + closed_base = self._last_closed_base_amount or Decimal("0") + closed_quote = self._last_closed_quote_amount or Decimal("0") + closed_base_fee = self._last_closed_base_fee or Decimal("0") + closed_quote_fee = self._last_closed_quote_fee or Decimal("0") + + # Calculate required amounts (handles negative offset internally) + base_amt, quote_amt = self._calculate_amounts(side, current_price) + + # Get current wallet balances + try: + base_balance = self.market_data_provider.get_balance( + self.config.connector_name, self._base_token + ) + quote_balance = self.market_data_provider.get_balance( + self.config.connector_name, self._quote_token + ) + except Exception as e: + self.logger().warning(f"Could not fetch balances for autoswap check: {e}") + return None + + # For rebalances, add closed position amounts to available balance + # (wallet balance may not be updated yet after position close) + if closed_base > 0 or closed_quote > 0: + base_balance += closed_base + closed_base_fee + quote_balance += closed_quote + closed_quote_fee + self.logger().info( + f"Autoswap: including closed position amounts in balance: " + f"+{closed_base + closed_base_fee:.6f} {self._base_token}, " + f"+{closed_quote + closed_quote_fee:.6f} {self._quote_token}" + ) + + # Calculate deficit from raw amounts + base_deficit = base_amt - base_balance + quote_deficit = quote_amt - quote_balance + + # Add 0.1 SOL buffer for rent and transaction fees when SOL is involved + sol_buffer = Decimal("0.1") + if self._base_token.upper() == "SOL": + base_deficit += sol_buffer + if self._quote_token.upper() == "SOL": + quote_deficit += sol_buffer + + self.logger().info( + f"Autoswap check: need base={base_amt:.6f}, have={base_balance:.6f}, deficit={base_deficit:.6f} | " + f"need quote={quote_amt:.6f}, have={quote_balance:.6f}, deficit={quote_deficit:.6f}" + ) + + # Buffer multiplier only applied to swap amount + buffer_multiplier = Decimal("1") + (self.config.swap_buffer_pct / Decimal("100")) + + # If any deficit, swap + if base_deficit > 0 and quote_deficit <= 0: + # Need more base, have enough quote - BUY base with quote + swap_amount = base_deficit * buffer_multiplier + # Check if we have enough quote to buy this much base + required_quote = swap_amount * current_price * Decimal("1.02") # 2% extra for price movement + if quote_balance >= required_quote: + self.logger().info( + f"Autoswap: BUY {swap_amount:.6f} {self._base_token} " + f"(deficit={base_deficit:.6f} + {self.config.swap_buffer_pct}% buffer, " + f"have {quote_balance:.6f} {self._quote_token})" + ) + return SwapExecutorConfig( + timestamp=self.market_data_provider.time(), + network=self.config.network, + trading_pair=self.config.trading_pair, + connector_name=self.config.swap_provider, + side=TradeType.BUY, + amount=swap_amount, + swap_providers=[self.config.swap_provider] if self.config.swap_provider else None, + ) + else: + self.logger().warning( + f"Autoswap: insufficient quote ({quote_balance:.6f}) to buy {swap_amount:.6f} base " + f"(need ~{required_quote:.6f} {self._quote_token})" + ) + return None + + elif quote_deficit > 0 and base_deficit <= 0: + # Need more quote, have enough base - SELL base for quote + swap_amount = (quote_deficit / current_price) * buffer_multiplier + # Check if we have enough base to sell + if base_balance >= swap_amount * Decimal("1.02"): # 2% extra for price movement + self.logger().info( + f"Autoswap: SELL {swap_amount:.6f} {self._base_token} for ~{quote_deficit:.6f} {self._quote_token} " + f"(deficit + {self.config.swap_buffer_pct}% buffer, have {base_balance:.6f} {self._base_token})" + ) + return SwapExecutorConfig( + timestamp=self.market_data_provider.time(), + network=self.config.network, + trading_pair=self.config.trading_pair, + connector_name=self.config.swap_provider, + side=TradeType.SELL, + amount=swap_amount, + swap_providers=[self.config.swap_provider] if self.config.swap_provider else None, + ) + else: + self.logger().warning( + f"Autoswap: insufficient base ({base_balance:.6f}) to sell for {quote_deficit:.6f} quote" + ) + return None + + elif base_deficit > 0 and quote_deficit > 0: + # Both tokens in deficit - user is underfunded for side=0 (BOTH) + total_deficit_quote = base_deficit * current_price + quote_deficit + self.logger().warning( + f"Autoswap: cannot swap - both tokens in deficit (side=0). " + f"Need {base_deficit:.6f} more {self._base_token} AND {quote_deficit:.6f} more {self._quote_token} " + f"(total deficit: {total_deficit_quote:.2f} {self._quote_token})" + ) + return None + + # No swap needed + return None + def _trigger_balance_update(self): """Trigger a balance update on the connector after position changes.""" try: @@ -201,6 +392,64 @@ def determine_executor_actions(self) -> List[ExecutorAction]: self.logger().debug(f"Could not capture initial balances: {e}") actions = [] + + # Check if swap executor is running (autoswap in progress) + if self._pending_swap_side is not None: + # Find and track the swap executor if not already tracked + if not self._swap_executor_id: + for e in self.executors_info: + if e.config.type == "swap_executor" and e.is_active: + self._swap_executor_id = e.id + self.logger().info(f"Tracking swap executor: {e.id}") + break + + # If swap is pending but executor not found yet, wait for it to appear + if not self._swap_executor_id: + self.logger().debug("Waiting for swap executor to appear in executors_info") + return actions + + if self._swap_executor_id: + if not self.is_swap_executor_done(): + swap_executor = self.get_swap_executor() + state = swap_executor.custom_info.get("state") if swap_executor else "unknown" + self.logger().debug(f"Waiting for swap executor to complete (state: {state})") + return actions + + # Swap executor completed - check result and proceed + swap_executor = self.get_swap_executor() + swap_state = swap_executor.custom_info.get("state") if swap_executor else "unknown" + pending_side = self._pending_swap_side + + # Clear swap tracking + self._swap_executor_id = None + self._pending_swap_side = None + + if swap_state == SwapExecutorStates.COMPLETED.value: + self.logger().info("Autoswap completed successfully, proceeding to LP position") + # Trigger balance update after successful swap + self._trigger_balance_update() + + # Create LP position with the side that was pending + if pending_side is not None: + executor_config = self._create_executor_config(pending_side) + if executor_config: + actions.append(CreateExecutorAction( + controller_id=self.config.id, + executor_config=executor_config + )) + self._initial_position_created = True + self._pending_balance_update = True + else: + # Swap failed - log error and skip LP position creation this cycle + self.logger().error( + f"Autoswap FAILED (state: {swap_state}). " + f"Will retry autoswap check on next cycle for side={pending_side}" + ) + # Don't create LP position - let the next cycle re-check balances + # and potentially retry the swap + + return actions + executor = self.active_executor() # Track the active executor's ID if we don't have one yet @@ -236,11 +485,38 @@ def determine_executor_actions(self) -> List[ExecutorAction]: # Determine side for new position if self._pending_rebalance and self._pending_rebalance_side is not None: + # Rebalance: use the side determined by price direction side = self._pending_rebalance_side self._pending_rebalance = False self._pending_rebalance_side = None - else: + elif not self._initial_position_created: + # Initial position: use configured side (can be 0=BOTH, 1=BUY, 2=SELL) side = self.config.side + else: + # After initial position but no pending rebalance (e.g., position failed/closed) + # Determine side from current price vs price limits + if not self._pool_price: + self.logger().info("Waiting for pool price to determine side") + return actions + side = self._determine_side_from_price(self._pool_price) + + # Check if autoswap is needed before creating LP position + if self.config.autoswap: + if not self._pool_price: + self.logger().info("Autoswap: waiting for pool price") + return actions + swap_config = self._check_autoswap_needed(side, self._pool_price) + if swap_config: + # Create swap executor and wait for it to complete + self._pending_swap_side = side + actions.append(CreateExecutorAction( + controller_id=self.config.id, + executor_config=swap_config + )) + # Track the swap executor ID on next tick + return actions + else: + self.logger().info("Autoswap: no swap needed, balances sufficient") # Create executor config with calculated bounds executor_config = self._create_executor_config(side) @@ -252,14 +528,23 @@ def determine_executor_actions(self) -> List[ExecutorAction]: controller_id=self.config.id, executor_config=executor_config )) + # Note: _initial_position_created is set below when position is confirmed active self._pending_balance_update = True + + # Clear closed position amounts after LP position is created + self._last_closed_base_amount = None + self._last_closed_quote_amount = None + self._last_closed_base_fee = None + self._last_closed_quote_fee = None + return actions - # Trigger balance update after position is created + # Mark initial position created and trigger balance update when position is active if self._pending_balance_update: state = executor.custom_info.get("state") if state in ("IN_RANGE", "OUT_OF_RANGE"): self._pending_balance_update = False + self._initial_position_created = True # Only mark created when actually active self._trigger_balance_update() # Check executor state @@ -313,7 +598,7 @@ def _handle_rebalance(self, executor: ExecutorInfo) -> Optional[StopExecutorActi # Don't log repeatedly - this is checked every tick return None - # Step 4: Initiate rebalance + # Step 3: Initiate rebalance self._pending_rebalance = True self._pending_rebalance_side = new_side self.logger().info( @@ -324,7 +609,6 @@ def _handle_rebalance(self, executor: ExecutorInfo) -> Optional[StopExecutorActi return StopExecutorAction( controller_id=self.config.id, executor_id=executor.id, - keep_position=False, ) def _is_beyond_rebalance_threshold(self, executor: ExecutorInfo) -> bool: @@ -411,72 +695,70 @@ def _create_executor_config(self, side: int) -> Optional[LPExecutorConfig]: base_amount=base_amt, quote_amount=quote_amt, side=side, - position_offset_pct=self.config.position_offset_pct, extra_params=extra_params if extra_params else None, - keep_position=False, ) def _calculate_amounts(self, side: int, current_price: Decimal) -> tuple: """ - Calculate base and quote amounts based on side and total_amount_quote. + Calculate base and quote amounts based on side, offset, and total_amount_quote. - For rebalances, clamps to the actual amounts returned from the closed position - to avoid order failures when balance is less than configured total (due to - impermanent loss, fees, or price movement). + Allocation logic: + - Side 0 (BOTH): split 50/50 + - Side 1/2 with offset >= 0 (out-of-range): 100% single-sided + - Side 1/2 with offset < 0 (in-range): proportional split based on price position - Side 0 (BOTH): split 50/50 - Side 1 (BUY): all quote - clamp to closed position's quote + quote_fee - Side 2 (SELL): all base - clamp to closed position's base + base_fee + For in-range positions, the split is calculated based on where current price + sits in the range. This mirrors CLMM behavior where both tokens are needed + when price is within bounds. + + Note: No clamping is done here - autoswap handles any token deficits. """ total = self.config.total_amount_quote - - # For rebalances, clamp to actual amounts from closed position - # Check if we have captured amounts (indicates this is a rebalance) - has_closed_amounts = (self._last_closed_base_amount is not None or - self._last_closed_quote_amount is not None) - if has_closed_amounts: - if side == 1: # BUY - needs quote token - if self._last_closed_quote_amount is not None: - # Total available = position amount + fees earned - available_quote = self._last_closed_quote_amount - if self._last_closed_quote_fee: - available_quote += self._last_closed_quote_fee - if available_quote < total: - self.logger().info( - f"Clamping quote amount from {total} to {available_quote} {self._quote_token} " - f"(closed position returned {self._last_closed_quote_amount} + {self._last_closed_quote_fee} fees)" - ) - total = available_quote - elif side == 2: # SELL - needs base token - if self._last_closed_base_amount is not None: - # Total available = position amount + fees earned - available_base = self._last_closed_base_amount - if self._last_closed_base_fee: - available_base += self._last_closed_base_fee - available_as_quote = available_base * current_price - if available_as_quote < total: - self.logger().info( - f"Clamping total from {total} to {available_as_quote:.4f} " - f"{self._quote_token} (closed: {self._last_closed_base_amount} + " - f"{self._last_closed_base_fee} fees {self._base_token})" - ) - total = available_as_quote - - # Clear the cached amounts after use - self._last_closed_base_amount = None - self._last_closed_quote_amount = None - self._last_closed_base_fee = None - self._last_closed_quote_fee = None + offset = self.config.position_offset_pct if side == 0: # BOTH quote_amt = total / Decimal("2") base_amt = quote_amt / current_price - elif side == 1: # BUY - base_amt = Decimal("0") - quote_amt = total - else: # SELL - base_amt = total / current_price - quote_amt = Decimal("0") + elif offset >= 0: + # Out-of-range: single-sided allocation + if side == 1: # BUY - all quote + base_amt = Decimal("0") + quote_amt = total + else: # SELL - all base + base_amt = total / current_price + quote_amt = Decimal("0") + else: + # In-range (offset < 0): proportional split based on price position in range + # Calculate bounds to determine where price sits + lower_price, upper_price = self._calculate_price_bounds(side, current_price) + price_range = upper_price - lower_price + + if price_range <= 0 or current_price <= lower_price: + # At or below lower bound - all quote for BUY, all base for SELL + if side == 1: + base_amt = Decimal("0") + quote_amt = total + else: + base_amt = total / current_price + quote_amt = Decimal("0") + elif current_price >= upper_price: + # At or above upper bound - all base for SELL, all quote for BUY + if side == 2: + base_amt = total / current_price + quote_amt = Decimal("0") + else: + base_amt = Decimal("0") + quote_amt = total + else: + # Price is in range - calculate proportional split + # price_ratio: 0 at lower_price, 1 at upper_price + price_ratio = (current_price - lower_price) / price_range + # As price goes up, more of the position is in quote, less in base + quote_pct = price_ratio + base_pct = Decimal("1") - price_ratio + + quote_amt = total * quote_pct + base_amt = (total * base_pct) / current_price return base_amt, quote_amt @@ -566,6 +848,42 @@ def _is_price_within_limits(self, price: Decimal, side: int) -> bool: return False return True + def _determine_side_from_price(self, current_price: Decimal) -> int: + """ + Determine side (1=BUY or 2=SELL) based on current price vs price limits. + + Used after initial position to ensure we never use side=0 (BOTH) for rebalances. + - If price is closer to buy range, use BUY (1) + - If price is closer to sell range, use SELL (2) + """ + # Get midpoints of buy and sell ranges + buy_mid = None + sell_mid = None + + if self.config.buy_price_min and self.config.buy_price_max: + buy_mid = (self.config.buy_price_min + self.config.buy_price_max) / 2 + if self.config.sell_price_min and self.config.sell_price_max: + sell_mid = (self.config.sell_price_min + self.config.sell_price_max) / 2 + + # If both ranges defined, use the one price is closer to + if buy_mid and sell_mid: + if current_price <= buy_mid: + return 1 # BUY - price in lower range + elif current_price >= sell_mid: + return 2 # SELL - price in upper range + else: + # Price between buy_mid and sell_mid - use BUY if closer to buy_mid + return 1 if (current_price - buy_mid) < (sell_mid - current_price) else 2 + + # If only one range defined, use that side + if buy_mid: + return 1 + if sell_mid: + return 2 + + # No price limits defined - default to BUY + return 1 + async def update_processed_data(self): """Called every tick - always fetch fresh pool price for accurate position creation.""" try: @@ -589,80 +907,87 @@ def to_format_status(self) -> List[str]: status.append(header + " " * (box_width - len(header) + 1) + "|") status.append("+" + "-" * box_width + "+") - # Network, connector, pool + # === CONFIG SECTION === line = f"| Network: {self.config.network}" status.append(line + " " * (box_width - len(line) + 1) + "|") line = f"| Pool: {self.config.pool_address}" status.append(line + " " * (box_width - len(line) + 1) + "|") - # Position info from current executor (active or transitioning) - executor = self.active_executor() or self.get_tracked_executor() - if executor and not executor.is_done: - position_address = executor.custom_info.get("position_address", "N/A") - line = f"| Position: {position_address}" - status.append(line + " " * (box_width - len(line) + 1) + "|") - # Config summary side_names = {0: "BOTH", 1: "BUY", 2: "SELL"} side_str = side_names.get(self.config.side, '?') amt = self.config.total_amount_quote width = self.config.position_width_pct + offset = self.config.position_offset_pct rebal = self.config.rebalance_seconds - line = f"| Config: side={side_str}, amount={amt} {self._quote_token}, width={width}%, rebal={rebal}s" + line = f"| Config: side={side_str}, amount={amt} {self._quote_token}, width={width}%, offset={offset}%, rebal={rebal}s" status.append(line + " " * (box_width - len(line) + 1) + "|") - # Position fees and assets + # Spacer before Position section + status.append("|" + " " * box_width + "|") + + # === POSITION SECTION === + executor = self.active_executor() or self.get_tracked_executor() + + # Get position amounts for balance calculations + pos_base_amount = Decimal("0") + pos_quote_amount = Decimal("0") + if executor and not executor.is_done: custom = executor.custom_info + # Position Address + position_address = custom.get("position_address", "N/A") + line = f"| Position: {position_address}" + status.append(line + " " * (box_width - len(line) + 1) + "|") + + # Assets row: base_amount + quote_amount = total value + pos_base_amount = Decimal(str(custom.get("base_amount", 0))) + pos_quote_amount = Decimal(str(custom.get("quote_amount", 0))) + total_value_quote = Decimal(str(custom.get("total_value_quote", 0))) + line = ( + f"| Assets: {float(pos_base_amount):.6f} {self._base_token} + " + f"{float(pos_quote_amount):.6f} {self._quote_token} = {float(total_value_quote):.4f} {self._quote_token}" + ) + status.append(line + " " * (box_width - len(line) + 1) + "|") + # Fees row: base_fee + quote_fee = total base_fee = Decimal(str(custom.get("base_fee", 0))) quote_fee = Decimal(str(custom.get("quote_fee", 0))) fees_earned_quote = Decimal(str(custom.get("fees_earned_quote", 0))) line = ( f"| Fees: {float(base_fee):.6f} {self._base_token} + " - f"{float(quote_fee):.6f} {self._quote_token} = {float(fees_earned_quote):.6f}" + f"{float(quote_fee):.6f} {self._quote_token} = {float(fees_earned_quote):.6f} {self._quote_token}" ) status.append(line + " " * (box_width - len(line) + 1) + "|") - # Value row: base_amount + quote_amount = total value - base_amount = Decimal(str(custom.get("base_amount", 0))) - quote_amount = Decimal(str(custom.get("quote_amount", 0))) - total_value_quote = Decimal(str(custom.get("total_value_quote", 0))) - line = ( - f"| Value: {float(base_amount):.6f} {self._base_token} + " - f"{float(quote_amount):.6f} {self._quote_token} = {float(total_value_quote):.4f}" - ) - status.append(line + " " * (box_width - len(line) + 1) + "|") + # Price and rebalance thresholds + lower_price = custom.get("lower_price") + upper_price = custom.get("upper_price") - # Position range visualization - lower_price = executor.custom_info.get("lower_price") - upper_price = executor.custom_info.get("upper_price") - - if lower_price and upper_price and self._pool_price: - # Show rebalance thresholds (convert % to decimal) - # Takes into account price limits - rebalance only happens within limits + if lower_price is not None and upper_price is not None and self._pool_price: threshold = self.config.rebalance_threshold_pct / Decimal("100") lower_threshold = Decimal(str(lower_price)) * (Decimal("1") - threshold) upper_threshold = Decimal(str(upper_price)) * (Decimal("1") + threshold) # Lower threshold triggers SELL - check sell_price_min if self.config.sell_price_min and lower_threshold < self.config.sell_price_min: - lower_str = "N/A" # Below sell limit, no rebalance possible + lower_str = "N/A" else: lower_str = f"{float(lower_threshold):.{price_decimals}f}" # Upper threshold triggers BUY - check buy_price_max if self.config.buy_price_max and upper_threshold > self.config.buy_price_max: - upper_str = "N/A" # Above buy limit, no rebalance possible + upper_str = "N/A" else: upper_str = f"{float(upper_threshold):.{price_decimals}f}" line = f"| Price: {float(self._pool_price):.{price_decimals}f} | Rebalance if: <{lower_str} or >{upper_str}" status.append(line + " " * (box_width - len(line) + 1) + "|") - state = executor.custom_info.get("state", "UNKNOWN") + # Status with icon + state = custom.get("state", "UNKNOWN") state_icons = { "IN_RANGE": "●", "OUT_OF_RANGE": "○", @@ -677,6 +1002,7 @@ def to_format_status(self) -> List[str]: line = f"| Position Status: [{state_icon} {state}]" status.append(line + " " * (box_width - len(line) + 1) + "|") + # Range visualization range_viz = self._create_price_range_visualization( Decimal(str(lower_price)), self._pool_price, @@ -686,27 +1012,28 @@ def to_format_status(self) -> List[str]: line = f"| {viz_line}" status.append(line + " " * (box_width - len(line) + 1) + "|") - # Show rebalance timer if out of range - out_of_range_seconds = executor.custom_info.get("out_of_range_seconds") + # Rebalance timer if out of range + out_of_range_seconds = custom.get("out_of_range_seconds") if out_of_range_seconds is not None: - # Check if beyond threshold beyond_threshold = self._is_beyond_rebalance_threshold(executor) if beyond_threshold: line = f"| Rebalance: {out_of_range_seconds}s / {self.config.rebalance_seconds}s" else: line = f"| Rebalance: waiting (below {float(self.config.rebalance_threshold_pct):.2f}% threshold)" status.append(line + " " * (box_width - len(line) + 1) + "|") + else: + line = "| Position: None" + status.append(line + " " * (box_width - len(line) + 1) + "|") - # Price limits visualization + # === PRICE LIMITS VISUALIZATION === has_limits = any([ self.config.sell_price_min, self.config.sell_price_max, self.config.buy_price_min, self.config.buy_price_max ]) if has_limits and self._pool_price: - # Get position bounds if available pos_lower = None pos_upper = None - if executor: + if executor and not executor.is_done: pos_lower = executor.custom_info.get("lower_price") pos_upper = executor.custom_info.get("upper_price") if pos_lower: @@ -723,77 +1050,96 @@ def to_format_status(self) -> List[str]: line = f"| {viz_line}" status.append(line + " " * (box_width - len(line) + 1) + "|") - # Balance comparison table (formatted like main balance table) + # === BALANCES === status.append("|" + " " * box_width + "|") try: - current_base = self.market_data_provider.get_balance( + wallet_base = self.market_data_provider.get_balance( self.config.connector_name, self._base_token ) - current_quote = self.market_data_provider.get_balance( + wallet_quote = self.market_data_provider.get_balance( self.config.connector_name, self._quote_token ) line = "| Balances:" status.append(line + " " * (box_width - len(line) + 1) + "|") - # Table header - header = f"| {'Asset':<12} {'Initial':>14} {'Current':>14} {'Change':>16}" + # Table header: Asset | Initial | Current (wallet) | Position | Change + header = f"| {'Asset':<8} {'Initial':>12} {'Current':>12} {'Position':>12} {'Change':>14}" status.append(header + " " * (box_width - len(header) + 1) + "|") # Base token row + # Change = (wallet + position) - initial if self._initial_base_balance is not None: - base_change = current_base - self._initial_base_balance + total_base = wallet_base + pos_base_amount + base_change = total_base - self._initial_base_balance init_b = float(self._initial_base_balance) - curr_b = float(current_base) + wall_b = float(wallet_base) + pos_b = float(pos_base_amount) chg_b = float(base_change) - line = f"| {self._base_token:<12} {init_b:>14.6f} {curr_b:>14.6f} {chg_b:>+16.6f}" + line = f"| {self._base_token:<8} {init_b:>12.6f} {wall_b:>12.6f} {pos_b:>12.6f} {chg_b:>+14.6f}" else: - curr_b = float(current_base) - line = f"| {self._base_token:<12} {'N/A':>14} {curr_b:>14.6f} {'N/A':>16}" + wall_b = float(wallet_base) + pos_b = float(pos_base_amount) + line = f"| {self._base_token:<8} {'N/A':>12} {wall_b:>12.6f} {pos_b:>12.6f} {'N/A':>14}" status.append(line + " " * (box_width - len(line) + 1) + "|") # Quote token row if self._initial_quote_balance is not None: - quote_change = current_quote - self._initial_quote_balance + total_quote = wallet_quote + pos_quote_amount + quote_change = total_quote - self._initial_quote_balance init_q = float(self._initial_quote_balance) - curr_q = float(current_quote) + wall_q = float(wallet_quote) + pos_q = float(pos_quote_amount) chg_q = float(quote_change) - line = f"| {self._quote_token:<12} {init_q:>14.6f} {curr_q:>14.6f} {chg_q:>+16.6f}" + line = f"| {self._quote_token:<8} {init_q:>12.6f} {wall_q:>12.6f} {pos_q:>12.6f} {chg_q:>+14.6f}" else: - curr_q = float(current_quote) - line = f"| {self._quote_token:<12} {'N/A':>14} {curr_q:>14.6f} {'N/A':>16}" + wall_q = float(wallet_quote) + pos_q = float(pos_quote_amount) + line = f"| {self._quote_token:<8} {'N/A':>12} {wall_q:>12.6f} {pos_q:>12.6f} {'N/A':>14}" status.append(line + " " * (box_width - len(line) + 1) + "|") except Exception as e: line = f"| Balances: Error fetching ({e})" status.append(line + " " * (box_width - len(line) + 1) + "|") - # Closed positions summary + # === CLOSED POSITIONS SUMMARY === status.append("|" + " " * box_width + "|") closed = [e for e in self.executors_info if e.is_done] - # Count closed by side (config.side: 0=both, 1=buy, 2=sell) - both_count = len([e for e in closed if getattr(e.config, "side", None) == 0]) - buy_count = len([e for e in closed if getattr(e.config, "side", None) == 1]) - sell_count = len([e for e in closed if getattr(e.config, "side", None) == 2]) + # Separate LP positions from swaps + closed_lp = [e for e in closed if getattr(e.config, "type", None) == "lp_executor"] + closed_swaps = [e for e in closed if getattr(e.config, "type", None) == "swap_executor"] - # Calculate fees from closed positions + # Count LP positions by side + both_count = len([e for e in closed_lp if getattr(e.config, "side", None) == 0]) + buy_count = len([e for e in closed_lp if getattr(e.config, "side", None) == 1]) + sell_count = len([e for e in closed_lp if getattr(e.config, "side", None) == 2]) + + # Calculate fees from closed LP positions total_fees_base = Decimal("0") total_fees_quote = Decimal("0") - for e in closed: + for e in closed_lp: total_fees_base += Decimal(str(e.custom_info.get("base_fee", 0))) total_fees_quote += Decimal(str(e.custom_info.get("quote_fee", 0))) pool_price = self._pool_price or Decimal("0") total_fees_value = total_fees_base * pool_price + total_fees_quote - line = f"| Closed: {len(closed)} (both:{both_count} buy:{buy_count} sell:{sell_count})" + line = f"| Closed Positions: {len(closed_lp)} (both:{both_count} buy:{buy_count} sell:{sell_count})" status.append(line + " " * (box_width - len(line) + 1) + "|") + + # Show swaps count if any + if closed_swaps: + swap_buy = len([e for e in closed_swaps if e.custom_info.get("side") == "BUY"]) + swap_sell = len([e for e in closed_swaps if e.custom_info.get("side") == "SELL"]) + line = f"| Swaps Executed: {len(closed_swaps)} (buy:{swap_buy} sell:{swap_sell})" + status.append(line + " " * (box_width - len(line) + 1) + "|") + fb = float(total_fees_base) fq = float(total_fees_quote) fv = float(total_fees_value) - line = f"| Fees Collected: {fb:.6f} {self._base_token} + {fq:.6f} {self._quote_token} = {fv:.6f}" + line = f"| Fees Collected: {fb:.6f} {self._base_token} + {fq:.6f} {self._quote_token} = {fv:.6f} {self._quote_token}" status.append(line + " " * (box_width - len(line) + 1) + "|") status.append("+" + "-" * box_width + "+") From 70db22cb17e51053e89f759ede8cfacf28d6f67a Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 1 Apr 2026 13:54:25 -0700 Subject: [PATCH 11/34] debug: add logging to order completion handler Add INFO level logging to _handle_order_completed to trace: - When the handler is called - Whether the order was found in DB - The status transition (old -> FILLED) This helps debug why some orders stay in OPEN status after retries. Co-Authored-By: Claude Opus 4.5 --- services/orders_recorder.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/services/orders_recorder.py b/services/orders_recorder.py index 90ca8119..cea84fb6 100644 --- a/services/orders_recorder.py +++ b/services/orders_recorder.py @@ -2,20 +2,14 @@ import logging import math import time - -from typing import Any, Optional, Union from datetime import datetime from decimal import Decimal, InvalidOperation +from typing import Any, Optional, Union -from hummingbot.core.event.event_forwarder import SourceInfoEventForwarder -from hummingbot.core.event.events import ( - TradeType, - BuyOrderCreatedEvent, - SellOrderCreatedEvent, - OrderFilledEvent, - MarketEvent -) from hummingbot.connector.connector_base import ConnectorBase +from hummingbot.core.event.event_forwarder import SourceInfoEventForwarder +from hummingbot.core.event.events import BuyOrderCreatedEvent, MarketEvent, OrderFilledEvent, SellOrderCreatedEvent, TradeType + from database import AsyncDatabaseManager, OrderRepository, TradeRepository # Initialize logger @@ -378,14 +372,21 @@ async def _handle_order_failed(self, event: Any): async def _handle_order_completed(self, event: Any): """Handle order completion events""" + logger.info(f"OrdersRecorder: _handle_order_completed called for {event.order_id}") try: async with self.db_manager.get_session_context() as session: order_repo = OrderRepository(session) order = await order_repo.get_order_by_client_id(event.order_id) if order: + old_status = order.status order.status = "FILLED" order.exchange_order_id = getattr(event, 'exchange_order_id', None) - - logger.debug(f"Recorded order completed: {event.order_id}") + logger.info( + f"OrdersRecorder: Updated order {event.order_id} from {old_status} to FILLED" + ) + else: + logger.warning(f"OrdersRecorder: Order {event.order_id} not found in DB") + + logger.info(f"OrdersRecorder: Completed handling order {event.order_id}") except Exception as e: - logger.error(f"Error recording order completion: {e}") \ No newline at end of file + logger.error(f"OrdersRecorder: Error recording order completion for {event.order_id}: {e}") \ No newline at end of file From f21dca2b094974c2fe1c2a36e78459c812ac4491 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 3 Apr 2026 08:31:59 -0700 Subject: [PATCH 12/34] refactor: update to new gateway connector architecture - connector_name is now network identifier (e.g., "solana-mainnet-beta") - Added dex_name for DEX protocol (e.g., "meteora", "orca") - Added trading_type for pool type (default "clmm") - Updated SwapExecutorConfig creation to use new fields - Updated LPExecutorConfig creation to include dex_name and trading_type - Updated unified_connector_service to detect Gateway by checking AllConnectorSettings - Updated executor examples in models/executors.py Co-Authored-By: Claude Opus 4.5 --- .../generic/lp_rebalancer/lp_rebalancer.py | 27 +++++++++----- models/executors.py | 13 +++---- services/unified_connector_service.py | 37 +++++++------------ 3 files changed, 38 insertions(+), 39 deletions(-) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index dcee3394..f3a52805 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -26,9 +26,18 @@ class LPRebalancerConfig(ControllerConfigBase): controller_name: str = "lp_rebalancer" candles_config: List[CandlesConfig] = [] + # Network as connector - e.g., "solana-mainnet-beta" + # This is the network connector that hummingbot connects to + connector_name: str = "solana-mainnet-beta" + + # DEX protocol - e.g., "orca", "meteora", "raydium" + # Used to construct gateway routes: connectors/{dex_name}/{trading_type}/... + dex_name: str = "meteora" + + # Pool type - default "clmm" for concentrated liquidity + trading_type: str = "clmm" + # Pool configuration (required) - connector_name: str = "meteora/clmm" - network: str = "solana-mainnet-beta" trading_pair: str = "" pool_address: str = "" @@ -316,12 +325,11 @@ def _check_autoswap_needed(self, side: int, current_price: Decimal) -> Optional[ ) return SwapExecutorConfig( timestamp=self.market_data_provider.time(), - network=self.config.network, + connector_name=self.config.connector_name, + swap_provider=self.config.swap_provider, trading_pair=self.config.trading_pair, - connector_name=self.config.swap_provider, side=TradeType.BUY, amount=swap_amount, - swap_providers=[self.config.swap_provider] if self.config.swap_provider else None, ) else: self.logger().warning( @@ -341,12 +349,11 @@ def _check_autoswap_needed(self, side: int, current_price: Decimal) -> Optional[ ) return SwapExecutorConfig( timestamp=self.market_data_provider.time(), - network=self.config.network, + connector_name=self.config.connector_name, + swap_provider=self.config.swap_provider, trading_pair=self.config.trading_pair, - connector_name=self.config.swap_provider, side=TradeType.SELL, amount=swap_amount, - swap_providers=[self.config.swap_provider] if self.config.swap_provider else None, ) else: self.logger().warning( @@ -688,6 +695,8 @@ def _create_executor_config(self, side: int) -> Optional[LPExecutorConfig]: return LPExecutorConfig( timestamp=self.market_data_provider.time(), connector_name=self.config.connector_name, + dex_name=self.config.dex_name, + trading_type=self.config.trading_type, trading_pair=self.config.trading_pair, pool_address=self.config.pool_address, lower_price=lower_price, @@ -908,7 +917,7 @@ def to_format_status(self) -> List[str]: status.append("+" + "-" * box_width + "+") # === CONFIG SECTION === - line = f"| Network: {self.config.network}" + line = f"| Network: {self.config.connector_name} DEX: {self.config.dex_name}/{self.config.trading_type}" status.append(line + " " * (box_width - len(line) + 1) + "|") line = f"| Pool: {self.config.pool_address}" diff --git a/models/executors.py b/models/executors.py index 0194d70e..20f242ba 100644 --- a/models/executors.py +++ b/models/executors.py @@ -252,16 +252,15 @@ class CreateExecutorRequest(BaseModel): "account_name": "master_account", "executor_config": { "type": "lp_executor", - "connector_name": "meteora", + "connector_name": "solana-mainnet-beta", + "dex_name": "meteora", + "trading_type": "clmm", "pool_address": "HTvjzsfX3yU6BUodCjZ5vZkUrAxMDTrBs3CJaq43ashR", - "network": "solana-mainnet-beta", "lower_price": "80", "upper_price": "100", "base_amount": "0", "quote_amount": "10.0", "side": 1, - "auto_close_above_range_seconds": None, - "auto_close_below_range_seconds": 300, "extra_params": {"strategyType": 0}, "keep_position": False } @@ -274,13 +273,13 @@ class CreateExecutorRequest(BaseModel): "account_name": "master_account", "executor_config": { "type": "swap_executor", - "connector_name": "jupiter/router", - "network": "solana-mainnet-beta", + "connector_name": "solana-mainnet-beta", + "swap_provider": "jupiter/router", "trading_pair": "SOL-USDC", "side": 2, "amount": "0.1", "slippage_pct": "0.5", - "swap_providers": ["jupiter/router", "meteora/clmm", "orca/clmm"] + "additional_swap_providers": ["meteora/clmm", "orca/clmm"] } } } diff --git a/services/unified_connector_service.py b/services/unified_connector_service.py index eb3735e6..830a29ed 100644 --- a/services/unified_connector_service.py +++ b/services/unified_connector_service.py @@ -22,8 +22,7 @@ from hummingbot.connector.connector_base import ConnectorBase from hummingbot.connector.connector_metrics_collector import TradeVolumeMetricCollector from hummingbot.connector.exchange_py_base import ExchangePyBase -from hummingbot.connector.gateway.gateway_lp import GatewayLp -from hummingbot.connector.gateway.gateway_swap import GatewaySwap +from hummingbot.connector.gateway.gateway import Gateway from hummingbot.connector.perpetual_derivative_py_base import PerpetualDerivativePyBase from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, TradeType from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState @@ -636,33 +635,25 @@ def _create_trading_connector( ) -> ConnectorBase: """Create a trading connector with API keys. - For gateway connectors (containing '/'), creates a GatewayLp connector - which auto-detects chain/network and uses the default wallet. + For Gateway network connectors (e.g., 'solana-mainnet-beta'), creates a unified + Gateway connector which auto-detects chain/network and uses the default wallet. + The dex_name and trading_type are passed to methods, not to the connector. """ BackendAPISecurity.login_account( account_name=account_name, secrets_manager=self.secrets_manager ) - # Gateway connectors (e.g., 'meteora/clmm', 'jupiter/router') are not in AllConnectorSettings - # Router connectors use GatewaySwap, LP connectors use GatewayLp - # Both auto-detect chain/network from gateway config - if '/' in connector_name: - _, connector_type = connector_name.split('/', 1) - if connector_type == 'router': - logger.info(f"Creating gateway swap connector: {connector_name}") - return GatewaySwap( - connector_name=connector_name, - trading_pairs=[], - trading_required=True, - ) - else: - logger.info(f"Creating gateway LP connector: {connector_name}") - return GatewayLp( - connector_name=connector_name, - trading_pairs=[], - trading_required=True, - ) + # Check if this is a Gateway network connector + # Gateway connectors are NOT in AllConnectorSettings (those are exchange connectors) + # Network format: "chain-network" (e.g., "solana-mainnet-beta", "ethereum-mainnet") + if connector_name not in self._conn_settings: + logger.info(f"Creating Gateway connector for network: {connector_name}") + return Gateway( + connector_name=connector_name, + trading_pairs=[], + trading_required=True, + ) conn_setting = self._conn_settings[connector_name] keys = BackendAPISecurity.api_keys(connector_name) From 192107985ada8316431738e5586700485dd70fc4 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 3 Apr 2026 15:18:47 -0700 Subject: [PATCH 13/34] fix: update get_price call to use dex and trading_type params The GatewayHttpClient.get_price() method signature uses dex and trading_type parameters, not connector. Parse pricing_connector into separate params. Co-Authored-By: Claude Opus 4.5 --- services/accounts_service.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/services/accounts_service.py b/services/accounts_service.py index 4726dc19..15125fee 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -2190,6 +2190,13 @@ async def _fetch_gateway_prices_immediate(self, chain: str, network: str, logger.warning(f"No pricing connector configured for chain '{chain}', skipping immediate price fetch") return prices + # Parse pricing connector into dex and trading_type (e.g., "jupiter/router" -> "jupiter", "router") + if "/" in pricing_connector: + dex_name, trading_type = pricing_connector.split("/", 1) + else: + dex_name = pricing_connector + trading_type = "router" + # Create tasks for all tokens in parallel tasks = [] task_tokens = [] @@ -2225,7 +2232,8 @@ async def _fetch_gateway_prices_immediate(self, chain: str, network: str, task = gateway_client.get_price( chain=chain, network=network, - connector=pricing_connector, + dex=dex_name, + trading_type=trading_type, base_asset=token, quote_asset=quote_asset, amount=Decimal("1"), From 13bd7d54bd1eadf11a4f8fe6178861d4e1659c93 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 7 Apr 2026 14:10:13 -0700 Subject: [PATCH 14/34] fix: update executor_service and lp_rebalancer for provider refactor - Update lp_rebalancer to use lp_provider field (format: "dex/trading_type") - Add lp_provider validation in executor_service for lp_executor - Update API example to use new lp_provider format Co-Authored-By: Claude Opus 4.5 --- .../generic/lp_rebalancer/lp_rebalancer.py | 28 +++++++++++-------- models/executors.py | 3 +- services/executor_service.py | 5 +++- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index f3a52805..3558dda3 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -8,6 +8,7 @@ from hummingbot.logger import HummingbotLogger from hummingbot.strategy_v2.controllers import ControllerBase, ControllerConfigBase from hummingbot.strategy_v2.executors.data_types import ConnectorPair +from hummingbot.strategy_v2.executors.gateway_utils import parse_provider from hummingbot.strategy_v2.executors.lp_executor.data_types import LPExecutorConfig, LPExecutorStates from hummingbot.strategy_v2.executors.swap_executor.data_types import SwapExecutorConfig, SwapExecutorStates from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction, StopExecutorAction @@ -21,21 +22,22 @@ class LPRebalancerConfig(ControllerConfigBase): Uses total_amount_quote and side for position sizing. Implements KEEP vs REBALANCE logic based on price limits. + + Provider Architecture: + - connector_name: The network identifier (e.g., "solana-mainnet-beta") + - lp_provider: LP provider in format "dex/trading_type" (e.g., "meteora/clmm") + - swap_provider: Optional swap provider for autoswap (e.g., "jupiter/router") """ controller_type: str = "generic" controller_name: str = "lp_rebalancer" candles_config: List[CandlesConfig] = [] - # Network as connector - e.g., "solana-mainnet-beta" - # This is the network connector that hummingbot connects to + # Network connector - e.g., "solana-mainnet-beta" connector_name: str = "solana-mainnet-beta" - # DEX protocol - e.g., "orca", "meteora", "raydium" - # Used to construct gateway routes: connectors/{dex_name}/{trading_type}/... - dex_name: str = "meteora" - - # Pool type - default "clmm" for concentrated liquidity - trading_type: str = "clmm" + # LP provider (required) - format: "dex/trading_type" + # Examples: "meteora/clmm", "orca/clmm", "raydium/clmm" + lp_provider: str = "meteora/clmm" # Pool configuration (required) trading_pair: str = "" @@ -158,6 +160,11 @@ def __init__(self, config: LPRebalancerConfig, *args, **kwargs): super().__init__(config, *args, **kwargs) self.config: LPRebalancerConfig = config + # Parse lp_provider into dex_name and trading_type for gateway calls + self.lp_dex_name, self.lp_trading_type = parse_provider( + config.lp_provider, default_trading_type="clmm" + ) + # Parse token symbols from trading pair parts = config.trading_pair.split("-") self._base_token: str = parts[0] if len(parts) >= 2 else "" @@ -695,8 +702,7 @@ def _create_executor_config(self, side: int) -> Optional[LPExecutorConfig]: return LPExecutorConfig( timestamp=self.market_data_provider.time(), connector_name=self.config.connector_name, - dex_name=self.config.dex_name, - trading_type=self.config.trading_type, + lp_provider=self.config.lp_provider, trading_pair=self.config.trading_pair, pool_address=self.config.pool_address, lower_price=lower_price, @@ -917,7 +923,7 @@ def to_format_status(self) -> List[str]: status.append("+" + "-" * box_width + "+") # === CONFIG SECTION === - line = f"| Network: {self.config.connector_name} DEX: {self.config.dex_name}/{self.config.trading_type}" + line = f"| Network: {self.config.connector_name} LP: {self.config.lp_provider}" status.append(line + " " * (box_width - len(line) + 1) + "|") line = f"| Pool: {self.config.pool_address}" diff --git a/models/executors.py b/models/executors.py index 20f242ba..713193ba 100644 --- a/models/executors.py +++ b/models/executors.py @@ -253,8 +253,7 @@ class CreateExecutorRequest(BaseModel): "executor_config": { "type": "lp_executor", "connector_name": "solana-mainnet-beta", - "dex_name": "meteora", - "trading_type": "clmm", + "lp_provider": "meteora/clmm", "pool_address": "HTvjzsfX3yU6BUodCjZ5vZkUrAxMDTrBs3CJaq43ashR", "lower_price": "80", "upper_price": "100", diff --git a/services/executor_service.py b/services/executor_service.py index 4813c573..ea92707d 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -360,13 +360,16 @@ async def create_executor( if executor_type == "lp_executor": pool_address = executor_config.get("pool_address") + lp_provider = executor_config.get("lp_provider") if not pool_address: raise HTTPException(status_code=400, detail="pool_address is required for lp_executor") + if not lp_provider: + raise HTTPException(status_code=400, detail="lp_provider is required for lp_executor (e.g., 'meteora/clmm')") # Use pool_address as trading_pair placeholder for metadata if not provided if not trading_pair: trading_pair = pool_address else: - # swap_executor requires trading_pair + # swap_executor: trading_pair required, swap_provider optional (uses network default) if not trading_pair: raise HTTPException(status_code=400, detail="trading_pair is required for swap_executor") From 9c96838eb0ba1a0360cdf073280453601f0171b1 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 7 Apr 2026 20:04:57 -0700 Subject: [PATCH 15/34] fix: initialize rate sources for gateway executors Initialize market data provider rate sources when creating swap/lp executors to ensure price lookups work correctly. Co-Authored-By: Claude Opus 4.5 --- services/executor_service.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/services/executor_service.py b/services/executor_service.py index ea92707d..7fac6312 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -14,7 +14,7 @@ from fastapi import HTTPException from hummingbot.strategy_v2.executors.arbitrage_executor.arbitrage_executor import ArbitrageExecutor from hummingbot.strategy_v2.executors.arbitrage_executor.data_types import ArbitrageExecutorConfig -from hummingbot.strategy_v2.executors.data_types import ExecutorConfigBase +from hummingbot.strategy_v2.executors.data_types import ConnectorPair, ExecutorConfigBase from hummingbot.strategy_v2.executors.dca_executor.data_types import DCAExecutorConfig from hummingbot.strategy_v2.executors.dca_executor.dca_executor import DCAExecutor from hummingbot.strategy_v2.executors.executor_base import ExecutorBase @@ -412,6 +412,12 @@ async def create_executor( detail=f"Failed to create executor: {str(e)}" ) + # Initialize rate sources for gateway executors (needed for price lookups) + if executor_type in ("swap_executor", "lp_executor") and trading_pair: + trading_interface.market_data_provider.initialize_rate_sources([ + ConnectorPair(connector_name=connector_name, trading_pair=trading_pair) + ]) + # Store executor and metadata executor_id = typed_config.id self._active_executors[executor_id] = executor From 1f89d4f9493375a7e9c744c11b7cb5bacea61439 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 7 Apr 2026 20:34:17 -0700 Subject: [PATCH 16/34] fix: remove broken rate source initialization for gateway executors AccountTradingInterface doesn't have market_data_provider attribute. Rate sources are initialized elsewhere when needed. Co-Authored-By: Claude Opus 4.5 --- services/executor_service.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/services/executor_service.py b/services/executor_service.py index 7fac6312..ea92707d 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -14,7 +14,7 @@ from fastapi import HTTPException from hummingbot.strategy_v2.executors.arbitrage_executor.arbitrage_executor import ArbitrageExecutor from hummingbot.strategy_v2.executors.arbitrage_executor.data_types import ArbitrageExecutorConfig -from hummingbot.strategy_v2.executors.data_types import ConnectorPair, ExecutorConfigBase +from hummingbot.strategy_v2.executors.data_types import ExecutorConfigBase from hummingbot.strategy_v2.executors.dca_executor.data_types import DCAExecutorConfig from hummingbot.strategy_v2.executors.dca_executor.dca_executor import DCAExecutor from hummingbot.strategy_v2.executors.executor_base import ExecutorBase @@ -412,12 +412,6 @@ async def create_executor( detail=f"Failed to create executor: {str(e)}" ) - # Initialize rate sources for gateway executors (needed for price lookups) - if executor_type in ("swap_executor", "lp_executor") and trading_pair: - trading_interface.market_data_provider.initialize_rate_sources([ - ConnectorPair(connector_name=connector_name, trading_pair=trading_pair) - ]) - # Store executor and metadata executor_id = typed_config.id self._active_executors[executor_id] = executor From 64dc4724b974acc925fa32a0b79c8926f7094f28 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 8 Apr 2026 06:35:05 -0700 Subject: [PATCH 17/34] refactor: remove SwapExecutor references, use OrderExecutor for swaps - Remove swap_executor from EXECUTOR_REGISTRY and validation - Remove swap_executor from EXECUTOR_TYPES literal - Remove swap_executor examples from API docs - Update lp_rebalancer to use OrderExecutor for autoswap - Sync with hummingbot changes that removed SwapExecutor Co-Authored-By: Claude Opus 4.5 --- .../generic/lp_rebalancer/lp_rebalancer.py | 61 +++++++++---------- models/executors.py | 18 ------ routers/executors.py | 5 -- services/executor_service.py | 34 ++++------- 4 files changed, 41 insertions(+), 77 deletions(-) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index 3558dda3..5ff49fbd 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -10,8 +10,9 @@ from hummingbot.strategy_v2.executors.data_types import ConnectorPair from hummingbot.strategy_v2.executors.gateway_utils import parse_provider from hummingbot.strategy_v2.executors.lp_executor.data_types import LPExecutorConfig, LPExecutorStates -from hummingbot.strategy_v2.executors.swap_executor.data_types import SwapExecutorConfig, SwapExecutorStates +from hummingbot.strategy_v2.executors.order_executor.data_types import ExecutionStrategy, OrderExecutorConfig from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction, StopExecutorAction +from hummingbot.strategy_v2.models.executors import CloseType from hummingbot.strategy_v2.models.executors_info import ExecutorInfo from pydantic import Field, field_validator, model_validator @@ -26,7 +27,7 @@ class LPRebalancerConfig(ControllerConfigBase): Provider Architecture: - connector_name: The network identifier (e.g., "solana-mainnet-beta") - lp_provider: LP provider in format "dex/trading_type" (e.g., "meteora/clmm") - - swap_provider: Optional swap provider for autoswap (e.g., "jupiter/router") + - autoswap: Uses OrderExecutor with network's configured swapProvider """ controller_type: str = "generic" controller_name: str = "lp_rebalancer" @@ -37,9 +38,9 @@ class LPRebalancerConfig(ControllerConfigBase): # LP provider (required) - format: "dex/trading_type" # Examples: "meteora/clmm", "orca/clmm", "raydium/clmm" - lp_provider: str = "meteora/clmm" + lp_provider: str = "orca/clmm" - # Pool configuration (required) + # Pool configuration trading_pair: str = "" pool_address: str = "" @@ -77,18 +78,13 @@ class LPRebalancerConfig(ControllerConfigBase): autoswap: bool = Field( default=False, json_schema_extra={"is_updatable": True}, - description="Automatically swap tokens if balance is insufficient for position" + description="Automatically swap tokens if balance is insufficient for position." ) swap_buffer_pct: Decimal = Field( default=Decimal("0.01"), json_schema_extra={"is_updatable": True}, description="Extra % to swap beyond deficit to account for slippage (e.g., 0.01 = 0.01%)" ) - swap_provider: Optional[str] = Field( - default=None, - json_schema_extra={"is_updatable": False}, - description="Swap provider for autoswap (e.g., 'jupiter/router'). Required if autoswap=True." - ) @field_validator("sell_price_min", "sell_price_max", "buy_price_min", "buy_price_max", mode="before") @classmethod @@ -123,17 +119,11 @@ def validate_price_limit_ranges(self): f"For in-range positions, |position_offset_pct| ({abs(self.position_offset_pct)}) " f"must not exceed position_width_pct ({self.position_width_pct})" ) - # swap_provider is required when autoswap is enabled - if self.autoswap and not self.swap_provider: - raise ValueError("swap_provider is required when autoswap=True") return self def update_markets(self, markets: MarketDict) -> MarketDict: - """Register the LP connector and swap provider with trading pair""" + """Register the LP connector with trading pair""" markets = markets.add_or_update(self.connector_name, self.trading_pair) - # Also register swap provider if autoswap is enabled - if self.autoswap and self.swap_provider: - markets = markets.add_or_update(self.swap_provider, self.trading_pair) return markets @@ -248,14 +238,13 @@ def is_swap_executor_done(self) -> bool: swap_executor = self.get_swap_executor() if swap_executor is None: return True - state = swap_executor.custom_info.get("state") - return state in (SwapExecutorStates.COMPLETED.value, SwapExecutorStates.FAILED.value) + return swap_executor.is_done - def _check_autoswap_needed(self, side: int, current_price: Decimal) -> Optional[SwapExecutorConfig]: + def _check_autoswap_needed(self, side: int, current_price: Decimal) -> Optional[OrderExecutorConfig]: """ Check if autoswap is needed and return swap config if so. - Returns SwapExecutorConfig if swap is needed, None otherwise. + Returns OrderExecutorConfig if swap is needed, None otherwise. Simply checks balance vs required amounts and swaps deficit + buffer if insufficient. Works for both positive offset (out-of-range) and negative offset (in-range) positions. @@ -330,13 +319,13 @@ def _check_autoswap_needed(self, side: int, current_price: Decimal) -> Optional[ f"(deficit={base_deficit:.6f} + {self.config.swap_buffer_pct}% buffer, " f"have {quote_balance:.6f} {self._quote_token})" ) - return SwapExecutorConfig( + return OrderExecutorConfig( timestamp=self.market_data_provider.time(), connector_name=self.config.connector_name, - swap_provider=self.config.swap_provider, trading_pair=self.config.trading_pair, side=TradeType.BUY, amount=swap_amount, + execution_strategy=ExecutionStrategy.MARKET, ) else: self.logger().warning( @@ -354,13 +343,13 @@ def _check_autoswap_needed(self, side: int, current_price: Decimal) -> Optional[ f"Autoswap: SELL {swap_amount:.6f} {self._base_token} for ~{quote_deficit:.6f} {self._quote_token} " f"(deficit + {self.config.swap_buffer_pct}% buffer, have {base_balance:.6f} {self._base_token})" ) - return SwapExecutorConfig( + return OrderExecutorConfig( timestamp=self.market_data_provider.time(), connector_name=self.config.connector_name, - swap_provider=self.config.swap_provider, trading_pair=self.config.trading_pair, side=TradeType.SELL, amount=swap_amount, + execution_strategy=ExecutionStrategy.MARKET, ) else: self.logger().warning( @@ -412,7 +401,7 @@ def determine_executor_actions(self) -> List[ExecutorAction]: # Find and track the swap executor if not already tracked if not self._swap_executor_id: for e in self.executors_info: - if e.config.type == "swap_executor" and e.is_active: + if e.config.type == "order_executor" and e.is_active: self._swap_executor_id = e.id self.logger().info(f"Tracking swap executor: {e.id}") break @@ -431,14 +420,15 @@ def determine_executor_actions(self) -> List[ExecutorAction]: # Swap executor completed - check result and proceed swap_executor = self.get_swap_executor() - swap_state = swap_executor.custom_info.get("state") if swap_executor else "unknown" pending_side = self._pending_swap_side # Clear swap tracking self._swap_executor_id = None self._pending_swap_side = None - if swap_state == SwapExecutorStates.COMPLETED.value: + # Check if swap succeeded (not failed) + swap_succeeded = swap_executor and swap_executor.close_type != CloseType.FAILED + if swap_succeeded: self.logger().info("Autoswap completed successfully, proceeding to LP position") # Trigger balance update after successful swap self._trigger_balance_update() @@ -455,8 +445,9 @@ def determine_executor_actions(self) -> List[ExecutorAction]: self._pending_balance_update = True else: # Swap failed - log error and skip LP position creation this cycle + close_type = swap_executor.close_type if swap_executor else "unknown" self.logger().error( - f"Autoswap FAILED (state: {swap_state}). " + f"Autoswap FAILED (close_type: {close_type}). " f"Will retry autoswap check on next cycle for side={pending_side}" ) # Don't create LP position - let the next cycle re-check balances @@ -900,11 +891,15 @@ def _determine_side_from_price(self, current_price: Decimal) -> int: return 1 async def update_processed_data(self): - """Called every tick - always fetch fresh pool price for accurate position creation.""" + """Called every tick - fetch pool price.""" try: connector = self.market_data_provider.get_connector(self.config.connector_name) if hasattr(connector, 'get_pool_info_by_address'): - pool_info = await connector.get_pool_info_by_address(self.config.pool_address) + pool_info = await connector.get_pool_info_by_address( + self.config.pool_address, + dex_name=self.lp_dex_name, + trading_type=self.lp_trading_type, + ) if pool_info and pool_info.price: self._pool_price = Decimal(str(pool_info.price)) except Exception as e: @@ -923,7 +918,7 @@ def to_format_status(self) -> List[str]: status.append("+" + "-" * box_width + "+") # === CONFIG SECTION === - line = f"| Network: {self.config.connector_name} LP: {self.config.lp_provider}" + line = f"| Network: {self.config.connector_name} | LP: {self.config.lp_provider}" status.append(line + " " * (box_width - len(line) + 1) + "|") line = f"| Pool: {self.config.pool_address}" @@ -1123,7 +1118,7 @@ def to_format_status(self) -> List[str]: # Separate LP positions from swaps closed_lp = [e for e in closed if getattr(e.config, "type", None) == "lp_executor"] - closed_swaps = [e for e in closed if getattr(e.config, "type", None) == "swap_executor"] + closed_swaps = [e for e in closed if getattr(e.config, "type", None) == "order_executor"] # Count LP positions by side both_count = len([e for e in closed_lp if getattr(e.config, "side", None) == 0]) diff --git a/models/executors.py b/models/executors.py index 713193ba..eadc0015 100644 --- a/models/executors.py +++ b/models/executors.py @@ -212,7 +212,6 @@ class PositionsSummaryResponse(BaseModel): "xemm_executor", "order_executor", "lp_executor", - "swap_executor" ] @@ -264,23 +263,6 @@ class CreateExecutorRequest(BaseModel): "keep_position": False } } - }, - { - "summary": "Swap Executor", - "description": "Execute a single swap on Gateway AMM connectors (Jupiter, Raydium, etc.)", - "value": { - "account_name": "master_account", - "executor_config": { - "type": "swap_executor", - "connector_name": "solana-mainnet-beta", - "swap_provider": "jupiter/router", - "trading_pair": "SOL-USDC", - "side": 2, - "amount": "0.1", - "slippage_pct": "0.5", - "additional_swap_providers": ["meteora/clmm", "orca/clmm"] - } - } } ] } diff --git a/routers/executors.py b/routers/executors.py index 2b56fb99..8cb8545c 100644 --- a/routers/executors.py +++ b/routers/executors.py @@ -241,11 +241,6 @@ async def get_available_executor_types(): "type": "lp_executor", "description": "LP position management for CLMM pools (Meteora, Raydium) ", "use_case": "Automated liquidity provision with position tracking" - }, - { - "type": "swap_executor", - "description": "Single swap execution on Gateway AMM connectors", - "use_case": "Executing swaps on DEXs like Jupiter with retry logic" } ] } diff --git a/services/executor_service.py b/services/executor_service.py index ea92707d..46cca2d6 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -26,8 +26,6 @@ from hummingbot.strategy_v2.executors.order_executor.order_executor import OrderExecutor from hummingbot.strategy_v2.executors.position_executor.data_types import PositionExecutorConfig from hummingbot.strategy_v2.executors.position_executor.position_executor import PositionExecutor -from hummingbot.strategy_v2.executors.swap_executor.data_types import SwapExecutorConfig -from hummingbot.strategy_v2.executors.swap_executor.swap_executor import SwapExecutor from hummingbot.strategy_v2.executors.twap_executor.data_types import TWAPExecutorConfig from hummingbot.strategy_v2.executors.twap_executor.twap_executor import TWAPExecutor from hummingbot.strategy_v2.executors.xemm_executor.data_types import XEMMExecutorConfig @@ -84,7 +82,6 @@ class ExecutorService: "xemm_executor": (XEMMExecutor, XEMMExecutorConfig), "order_executor": (OrderExecutor, OrderExecutorConfig), "lp_executor": (LPExecutor, LPExecutorConfig), - "swap_executor": (SwapExecutor, SwapExecutorConfig), } def __init__( @@ -352,26 +349,21 @@ async def create_executor( connector_name = executor_config.get("connector_name") trading_pair = executor_config.get("trading_pair") - if executor_type in ("swap_executor", "lp_executor"): - # Gateway executors: connector_name required, trading_pair optional + if executor_type == "lp_executor": + # LP executor: connector_name required, trading_pair optional # Network is optional - uses connector's defaultNetwork if not provided if not connector_name: - raise HTTPException(status_code=400, detail=f"connector_name is required for {executor_type}") - - if executor_type == "lp_executor": - pool_address = executor_config.get("pool_address") - lp_provider = executor_config.get("lp_provider") - if not pool_address: - raise HTTPException(status_code=400, detail="pool_address is required for lp_executor") - if not lp_provider: - raise HTTPException(status_code=400, detail="lp_provider is required for lp_executor (e.g., 'meteora/clmm')") - # Use pool_address as trading_pair placeholder for metadata if not provided - if not trading_pair: - trading_pair = pool_address - else: - # swap_executor: trading_pair required, swap_provider optional (uses network default) - if not trading_pair: - raise HTTPException(status_code=400, detail="trading_pair is required for swap_executor") + raise HTTPException(status_code=400, detail="connector_name is required for lp_executor") + + pool_address = executor_config.get("pool_address") + lp_provider = executor_config.get("lp_provider") + if not pool_address: + raise HTTPException(status_code=400, detail="pool_address is required for lp_executor") + if not lp_provider: + raise HTTPException(status_code=400, detail="lp_provider is required for lp_executor (e.g., 'meteora/clmm')") + # Use pool_address as trading_pair placeholder for metadata if not provided + if not trading_pair: + trading_pair = pool_address # Ensure connector is ready (executor handles connector normalization in on_start) await trading_interface.ensure_connector(connector_name) From 885deed420be1e955c19c7b9ac1f7075dbde1855 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 8 Apr 2026 06:54:08 -0700 Subject: [PATCH 18/34] refactor: remove redundant executor validation, let Pydantic handle it LPExecutorConfig already requires connector_name, pool_address, lp_provider via Pydantic field definitions. No need to duplicate validation in API layer. Co-Authored-By: Claude Opus 4.5 --- services/executor_service.py | 30 +++++++----------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/services/executor_service.py b/services/executor_service.py index 46cca2d6..734d8e12 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -350,31 +350,15 @@ async def create_executor( trading_pair = executor_config.get("trading_pair") if executor_type == "lp_executor": - # LP executor: connector_name required, trading_pair optional - # Network is optional - uses connector's defaultNetwork if not provided - if not connector_name: - raise HTTPException(status_code=400, detail="connector_name is required for lp_executor") - - pool_address = executor_config.get("pool_address") - lp_provider = executor_config.get("lp_provider") - if not pool_address: - raise HTTPException(status_code=400, detail="pool_address is required for lp_executor") - if not lp_provider: - raise HTTPException(status_code=400, detail="lp_provider is required for lp_executor (e.g., 'meteora/clmm')") - # Use pool_address as trading_pair placeholder for metadata if not provided + # LP executor doesn't need trading_pair for market init - use pool_address for metadata if not trading_pair: - trading_pair = pool_address - - # Ensure connector is ready (executor handles connector normalization in on_start) - await trading_interface.ensure_connector(connector_name) + trading_pair = executor_config.get("pool_address") + if connector_name: + await trading_interface.ensure_connector(connector_name) else: - # Standard executors: both connector_name and trading_pair required - if not connector_name: - raise HTTPException(status_code=400, detail="connector_name is required in executor_config") - if not trading_pair: - raise HTTPException(status_code=400, detail="trading_pair is required in executor_config") - # Ensure connector and market are ready - await trading_interface.add_market(connector_name, trading_pair) + # Standard executors need connector and market initialized + if connector_name and trading_pair: + await trading_interface.add_market(connector_name, trading_pair) # Set timestamp if not provided (required for time-based features like time_limit) if "timestamp" not in executor_config or executor_config["timestamp"] is None: From b29e1112b98b9bf30fe0d47c03943b2b20c49274 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 8 Apr 2026 07:09:20 -0700 Subject: [PATCH 19/34] cleanup: fix lint issues in orders_recorder, remove verbose debug logging Co-Authored-By: Claude Opus 4.5 --- services/orders_recorder.py | 111 ++++++++++++++++++------------------ 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/services/orders_recorder.py b/services/orders_recorder.py index cea84fb6..48d7a77b 100644 --- a/services/orders_recorder.py +++ b/services/orders_recorder.py @@ -21,20 +21,20 @@ class OrdersRecorder: Custom orders recorder that mimics Hummingbot's MarketsRecorder functionality but uses our AsyncDatabaseManager for storage. """ - + def __init__(self, db_manager: AsyncDatabaseManager, account_name: str, connector_name: str): self.db_manager = db_manager self.account_name = account_name self.connector_name = connector_name self._connector: Optional[ConnectorBase] = None - + # Create event forwarders similar to MarketsRecorder self._create_order_forwarder = SourceInfoEventForwarder(self._did_create_order) self._fill_order_forwarder = SourceInfoEventForwarder(self._did_fill_order) self._cancel_order_forwarder = SourceInfoEventForwarder(self._did_cancel_order) self._fail_order_forwarder = SourceInfoEventForwarder(self._did_fail_order) self._complete_order_forwarder = SourceInfoEventForwarder(self._did_complete_order) - + # Event pairs mapping events to forwarders self._event_pairs = [ (MarketEvent.BuyOrderCreated, self._create_order_forwarder), @@ -45,12 +45,14 @@ def __init__(self, db_manager: AsyncDatabaseManager, account_name: str, connecto (MarketEvent.BuyOrderCompleted, self._complete_order_forwarder), (MarketEvent.SellOrderCompleted, self._complete_order_forwarder), ] - + def start(self, connector: ConnectorBase): """Start recording orders for the given connector""" # Idempotency guard: prevent double-registration of listeners if self._connector is not None: - logger.warning(f"OrdersRecorder already started for {self.account_name}/{self.connector_name}, ignoring duplicate start") + logger.warning( + f"OrdersRecorder already started for {self.account_name}/{self.connector_name}" + ) return self._connector = connector @@ -59,37 +61,40 @@ def start(self, connector: ConnectorBase): for event, forwarder in self._event_pairs: connector.add_listener(event, forwarder) logger.info(f"OrdersRecorder: Added listener for {event} with forwarder {forwarder}") - + # Debug: Check if listeners were actually added if hasattr(connector, '_event_listeners'): listeners = connector._event_listeners.get(event, []) logger.info(f"OrdersRecorder: Event {event} now has {len(listeners)} listeners") for i, listener in enumerate(listeners): logger.info(f"OrdersRecorder: Listener {i}: {listener}") - - logger.info(f"OrdersRecorder started for {self.account_name}/{self.connector_name} with {len(self._event_pairs)} event listeners") - + + logger.info( + f"OrdersRecorder started for {self.account_name}/{self.connector_name} " + f"with {len(self._event_pairs)} event listeners" + ) + # Debug: Print connector info logger.info(f"OrdersRecorder: Connector type: {type(connector)}") logger.info(f"OrdersRecorder: Connector name: {getattr(connector, 'name', 'unknown')}") logger.info(f"OrdersRecorder: Connector ready: {getattr(connector, 'ready', 'unknown')}") - + # Test if forwarders are callable for event, forwarder in self._event_pairs: if callable(forwarder): logger.info(f"OrdersRecorder: Forwarder for {event} is callable") else: logger.error(f"OrdersRecorder: Forwarder for {event} is NOT callable: {type(forwarder)}") - + async def stop(self): """Stop recording orders""" if self._connector: # Remove all event listeners for event, forwarder in self._event_pairs: self._connector.remove_listener(event, forwarder) - + logger.info(f"OrdersRecorder stopped for {self.account_name}/{self.connector_name}") - + def _extract_error_message(self, event) -> str: """Extract error message from various possible event attributes.""" # Try different possible attribute names for error messages @@ -98,10 +103,10 @@ def _extract_error_message(self, event) -> str: error_value = getattr(event, attr_name) if error_value: return str(error_value) - + # If no error message found, create a descriptive one return f"Order failed: {event.__class__.__name__}" - + def _did_create_order(self, event_tag: int, market: ConnectorBase, event: Union[BuyOrderCreatedEvent, SellOrderCreatedEvent]): """Handle order creation events - called by SourceInfoEventForwarder""" logger.info(f"OrdersRecorder: _did_create_order called for order {getattr(event, 'order_id', 'unknown')}") @@ -112,61 +117,64 @@ def _did_create_order(self, event_tag: int, market: ConnectorBase, event: Union[ asyncio.create_task(self._handle_order_created(event, trade_type)) except Exception as e: logger.error(f"Error in _did_create_order: {e}") - + def _did_fill_order(self, event_tag: int, market: ConnectorBase, event: OrderFilledEvent): """Handle order fill events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_filled(event)) except Exception as e: logger.error(f"Error in _did_fill_order: {e}") - + def _did_cancel_order(self, event_tag: int, market: ConnectorBase, event: Any): """Handle order cancel events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_cancelled(event)) except Exception as e: logger.error(f"Error in _did_cancel_order: {e}") - + def _did_fail_order(self, event_tag: int, market: ConnectorBase, event: Any): """Handle order failure events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_failed(event)) except Exception as e: logger.error(f"Error in _did_fail_order: {e}") - + def _did_complete_order(self, event_tag: int, market: ConnectorBase, event: Any): """Handle order completion events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_completed(event)) except Exception as e: logger.error(f"Error in _did_complete_order: {e}") - + async def _handle_order_created(self, event: Union[BuyOrderCreatedEvent, SellOrderCreatedEvent], trade_type: TradeType): """Handle order creation events""" logger.info(f"OrdersRecorder: _handle_order_created started for order {event.order_id}") try: async with self.db_manager.get_session_context() as session: order_repo = OrderRepository(session) - + # Check if order already exists first existing_order = await order_repo.get_order_by_client_id(event.order_id) if existing_order: logger.info(f"OrdersRecorder: Order {event.order_id} already exists with status {existing_order.status}") - + # Update exchange_order_id if we have it now and it was missing exchange_order_id = getattr(event, 'exchange_order_id', None) if exchange_order_id and not existing_order.exchange_order_id: existing_order.exchange_order_id = exchange_order_id - logger.info(f"OrdersRecorder: Updated exchange_order_id to {exchange_order_id} for order {event.order_id}") - + logger.info( + f"OrdersRecorder: Updated exchange_order_id to {exchange_order_id} " + f"for order {event.order_id}" + ) + # Update status if it's still in PENDING_CREATE or similar early state if existing_order.status in ["PENDING_CREATE", "PENDING", "SUBMITTED"]: existing_order.status = "OPEN" logger.info(f"OrdersRecorder: Updated status to OPEN for order {event.order_id}") - + await session.flush() return - + order_data = { "client_order_id": event.order_id, "account_name": self.account_name, @@ -180,11 +188,11 @@ async def _handle_order_created(self, event: Union[BuyOrderCreatedEvent, SellOrd "exchange_order_id": getattr(event, 'exchange_order_id', None) } await order_repo.create_order(order_data) - + logger.info(f"OrdersRecorder: Successfully recorded order created: {event.order_id}") except Exception as e: logger.error(f"OrdersRecorder: Error recording order created: {e}") - + async def _handle_order_filled(self, event: OrderFilledEvent): """Handle order fill events""" try: @@ -195,7 +203,7 @@ async def _handle_order_filled(self, event: OrderFilledEvent): # Calculate fees trade_fee_paid = 0 trade_fee_currency = None - + if event.trade_fee: try: base_asset, quote_asset = event.trading_pair.split("-") @@ -212,13 +220,13 @@ async def _handle_order_filled(self, event: OrderFilledEvent): logger.error(f"Error calculating trade fee: {e}") trade_fee_paid = 0 trade_fee_currency = None - + # Update order with fill information (handle potential NaN values like Hummingbot does) try: filled_amount = Decimal(str(event.amount)) average_fill_price = Decimal(str(event.price)) fee_paid_decimal = Decimal(str(trade_fee_paid)) if trade_fee_paid else None - + order = await order_repo.update_order_fill( client_order_id=event.order_id, filled_amount=filled_amount, @@ -229,12 +237,13 @@ async def _handle_order_filled(self, event: OrderFilledEvent): except (ValueError, InvalidOperation) as e: logger.error(f"Error processing order fill for {event.order_id}: {e}, skipping update") return - + # Create trade record using validated values if order: try: # Validate all values before creating trade record - validated_timestamp = event.timestamp if event.timestamp and not math.isnan(event.timestamp) else time.time() + has_valid_ts = event.timestamp and not math.isnan(event.timestamp) + validated_timestamp = event.timestamp if has_valid_ts else time.time() validated_fee = trade_fee_paid if trade_fee_paid and not math.isnan(trade_fee_paid) else 0 # Use exchange_trade_id if available (unique per fill), fallback to generated id @@ -261,12 +270,11 @@ async def _handle_order_filled(self, event: OrderFilledEvent): logger.debug(f"Trade {trade_id} already exists, skipping duplicate") except (ValueError, TypeError) as e: logger.error(f"Error creating trade record for {event.order_id}: {e}") - logger.error(f"Trade data that failed: timestamp={event.timestamp}, amount={event.amount}, price={event.price}, fee={trade_fee_paid}") - + logger.debug(f"Recorded order fill: {event.order_id} - {event.amount} @ {event.price}") except Exception as e: logger.error(f"Error recording order fill: {e}") - + async def _handle_order_cancelled(self, event: Any): """Handle order cancellation events""" try: @@ -276,11 +284,11 @@ async def _handle_order_cancelled(self, event: Any): client_order_id=event.order_id, status="CANCELLED" ) - + logger.debug(f"Recorded order cancelled: {event.order_id}") except Exception as e: logger.error(f"Error recording order cancellation: {e}") - + def _get_order_details_from_connector(self, order_id: str) -> Optional[dict]: """Try to get order details from connector's tracked orders""" try: @@ -297,19 +305,19 @@ def _get_order_details_from_connector(self, order_id: str) -> Optional[dict]: except Exception as e: logger.error(f"Error getting order details from connector: {e}") return None - + async def _handle_order_failed(self, event: Any): """Handle order failure events""" try: async with self.db_manager.get_session_context() as session: order_repo = OrderRepository(session) - + # Check if order exists, if not try to get details from connector's tracked orders existing_order = await order_repo.get_order_by_client_id(event.order_id) if existing_order: # Extract error message from various possible attributes error_msg = self._extract_error_message(event) - + # Update existing order with failure status and error message await order_repo.update_order_status( client_order_id=event.order_id, @@ -322,7 +330,7 @@ async def _handle_order_failed(self, event: Any): order_details = self._get_order_details_from_connector(event.order_id) if order_details: logger.info(f"Retrieved order details from connector for {event.order_id}: {order_details}") - + # Create order record as FAILED with available details if order_details: order_data = { @@ -344,14 +352,14 @@ async def _handle_order_failed(self, event: Any): "account_name": self.account_name, "connector_name": self.connector_name, "trading_pair": "UNKNOWN", - "trade_type": "UNKNOWN", + "trade_type": "UNKNOWN", "order_type": "UNKNOWN", "amount": 0.0, "price": None, "status": "FAILED", "error_message": self._extract_error_message(event) } - + try: await order_repo.create_order(order_data) logger.info(f"Created failed order record for {event.order_id}") @@ -366,27 +374,20 @@ async def _handle_order_failed(self, event: Any): ) else: raise create_error - + except Exception as e: logger.error(f"Error recording order failure: {e}") - + async def _handle_order_completed(self, event: Any): """Handle order completion events""" - logger.info(f"OrdersRecorder: _handle_order_completed called for {event.order_id}") try: async with self.db_manager.get_session_context() as session: order_repo = OrderRepository(session) order = await order_repo.get_order_by_client_id(event.order_id) if order: - old_status = order.status order.status = "FILLED" order.exchange_order_id = getattr(event, 'exchange_order_id', None) - logger.info( - f"OrdersRecorder: Updated order {event.order_id} from {old_status} to FILLED" - ) - else: - logger.warning(f"OrdersRecorder: Order {event.order_id} not found in DB") - logger.info(f"OrdersRecorder: Completed handling order {event.order_id}") + logger.debug(f"Recorded order completed: {event.order_id}") except Exception as e: - logger.error(f"OrdersRecorder: Error recording order completion for {event.order_id}: {e}") \ No newline at end of file + logger.error(f"Error recording order completion: {e}") From dddf487ca3a9ff01427d5d9d20254c9328dbcc0e Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 8 Apr 2026 08:27:38 -0700 Subject: [PATCH 20/34] Remove lphistory endpoint - not needed for API-managed executors API-managed LP executors already track performance in PostgreSQL database via ExecutorRecord table with net_pnl_quote, net_pnl_pct, etc. The lphistory endpoint was redundant as this data is stored locally. - Remove /bot-orchestration/{bot_name}/lphistory endpoint - Remove get_bot_lp_history method from BotsOrchestrator - Remove lphistory from MQTT command channel handling - Fix lint issues (trailing whitespace, unused variables) Co-Authored-By: Claude Opus 4.5 --- routers/bot_orchestration.py | 45 ----------------------------------- services/bots_orchestrator.py | 41 ++++--------------------------- utils/mqtt_manager.py | 14 +++++------ 3 files changed, 12 insertions(+), 88 deletions(-) diff --git a/routers/bot_orchestration.py b/routers/bot_orchestration.py index 994fe60f..8224c25d 100644 --- a/routers/bot_orchestration.py +++ b/routers/bot_orchestration.py @@ -122,51 +122,6 @@ async def get_bot_history( return {"status": "success", "response": response} -@router.get("/{bot_name}/lphistory") -async def get_bot_lp_history( - bot_name: str, - days: int = 0, - verbose: bool = False, - precision: int = None, - timeout: float = 30.0, - bots_manager: BotsOrchestrator = Depends(get_bots_orchestrator) -): - """ - Get LP (liquidity provider) position history for a bot. - - This endpoint returns LP-specific data including position updates, - fees collected, and liquidity additions/removals. Use this for - AMM/CLMM strategies like Meteora. - - Args: - bot_name: Name of the bot to get LP history for - days: Number of days of history to retrieve (0 for all) - verbose: Whether to include verbose output - precision: Decimal precision for numerical values - timeout: Timeout in seconds for the operation - bots_manager: Bot orchestrator service dependency - - Returns: - Dictionary with LP position history including: - - position_address: The LP position address - - order_action: ADD or REMOVE - - trading_pair: The trading pair (e.g., SOL-USDC) - - base_amount, quote_amount: Amounts added/removed - - base_fee, quote_fee: Fees collected - - lower_price, upper_price: Price range of position - - mid_price: Price at time of operation - - trade_fee: Transaction fees paid - """ - response = await bots_manager.get_bot_lp_history( - bot_name, - days=days, - verbose=verbose, - precision=precision, - timeout=timeout - ) - return {"status": "success", "response": response} - - @router.post("/start-bot") async def start_bot( action: StartBotAction, diff --git a/services/bots_orchestrator.py b/services/bots_orchestrator.py index 887700af..a16b8f80 100644 --- a/services/bots_orchestrator.py +++ b/services/bots_orchestrator.py @@ -32,7 +32,7 @@ def __init__(self, broker_host, broker_port, broker_username, broker_password): # Active bots tracking self.active_bots = {} self._update_bots_task: Optional[asyncio.Task] = None - + # Track bots that are currently being stopped and archived self.stopping_bots = set() @@ -220,36 +220,6 @@ async def get_bot_history(self, bot_name, **kwargs): return {"success": True, "data": response} - async def get_bot_lp_history(self, bot_name, **kwargs): - """ - Request bot LP (liquidity provider) history and wait for the response. - This returns LP position updates from RangePositionUpdate records. - """ - if bot_name not in self.active_bots: - logger.warning(f"Bot {bot_name} not found in active bots") - return {"success": False, "message": f"Bot {bot_name} not found"} - - # Create LPHistoryCommandMessage.Request format - data = { - "days": kwargs.get("days", 0), - "verbose": kwargs.get("verbose", False), - "precision": kwargs.get("precision"), - "async_backend": kwargs.get("async_backend", False), - } - - # Use the new RPC method to wait for response - timeout = kwargs.get("timeout", 30.0) # Default 30 second timeout - response = await self.mqtt_manager.publish_command_and_wait(bot_name, "lphistory", data, timeout=timeout) - - if response is None: - return { - "success": False, - "message": f"No response received from {bot_name} within {timeout} seconds", - "timeout": True, - } - - return {"success": True, "data": response} - @staticmethod def determine_controller_performance(controller_reports): """Process controller reports and extract performance and custom_info. @@ -333,7 +303,7 @@ def get_bot_status(self, bot_name): "general_logs": [], "recently_active": False, } - + # Get data from MQTT manager controller_reports = self.mqtt_manager.get_bot_controller_reports(bot_name) performance = self.determine_controller_performance(controller_reports) @@ -361,18 +331,17 @@ def get_bot_status(self, bot_name): } except Exception as e: return {"status": "error", "error": str(e)} - + def set_bot_stopping(self, bot_name: str): """Mark a bot as currently being stopped and archived.""" self.stopping_bots.add(bot_name) logger.info(f"Marked bot {bot_name} as stopping") - + def clear_bot_stopping(self, bot_name: str): """Clear the stopping status for a bot.""" self.stopping_bots.discard(bot_name) logger.info(f"Cleared stopping status for bot {bot_name}") - + def is_bot_stopping(self, bot_name: str) -> bool: """Check if a bot is currently being stopped.""" return bot_name in self.stopping_bots - diff --git a/utils/mqtt_manager.py b/utils/mqtt_manager.py index 63286c87..a0a3888b 100644 --- a/utils/mqtt_manager.py +++ b/utils/mqtt_manager.py @@ -33,7 +33,7 @@ def __init__(self, host: str, port: int, username: str, password: str): # Auto-discovered bots self._discovered_bots: Dict[str, float] = {} # bot_id: last_seen_timestamp - + # Message deduplication tracking self._processed_messages: Dict[str, float] = {} # message_hash: timestamp self._message_ttl = 300 # 5 minutes TTL for processed messages @@ -90,7 +90,7 @@ async def _get_client(self): for topic, qos in self._subscriptions: await client.subscribe(topic, qos=qos) yield client - + # Cleanup on exit self._connected = False @@ -151,7 +151,7 @@ async def _process_message(self, message): await self._handle_command_response(bot_id, channel, data) elif channel.startswith("external/event/"): await self._handle_external_event(bot_id, channel, data) - elif channel in ["history", "lphistory", "start", "stop", "config", "import_strategy"]: + elif channel in ["history", "start", "stop", "config", "import_strategy"]: # These are command channels - responses should come on response/* topics logger.debug(f"Command channel '{channel}' for bot {bot_id} - waiting for response") else: @@ -205,14 +205,14 @@ async def _handle_log(self, bot_id: str, data: Any): level = data.get("level_name") or data.get("levelname") or data.get("level", "INFO") message = data.get("msg") or data.get("message", "") timestamp = data.get("timestamp") or data.get("time") or time.time() - + # Create hash for deduplication (bot_id + message + timestamp within 1 second) message_hash = f"{bot_id}:{message}:{int(timestamp)}" elif isinstance(data, str): message = data timestamp = time.time() level = "INFO" - + # Create hash for string messages message_hash = f"{bot_id}:{message}:{int(timestamp)}" else: @@ -270,7 +270,7 @@ async def _handle_events(self, bot_id: str, data: Any): async def _handle_external_event(self, bot_id: str, channel: str, data: Any): """Handle external events.""" - event_type = channel.split("/")[-1] + _ = channel.split("/")[-1] # event_type for future use async def _handle_rpc_response(self, topic: str, message): """Handle RPC responses on hummingbot-api/response/* topics.""" @@ -297,7 +297,7 @@ async def _handle_command_response(self, bot_id: str, channel: str, data: Any): # Extract command from response channel (e.g., response/start/1234567890 or response/history) channel_parts = channel.split("/") if len(channel_parts) >= 2: - command = channel_parts[1] + _ = channel_parts[1] # command for future use async def start(self): """Start the MQTT client.""" From 3b096e31ba38e650e44f34361cba78246468ac99 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 8 Apr 2026 09:33:09 -0700 Subject: [PATCH 21/34] fix: revert lint-only changes, simplify executor connector init - Revert orders_recorder.py and mqtt_manager.py (lint-only changes) - Simplify executor creation: use add_market if trading_pair exists, otherwise ensure_connector Co-Authored-By: Claude Opus 4.5 --- services/executor_service.py | 14 +-- services/orders_recorder.py | 186 ++++++++++++++++++++++++----------- utils/mqtt_manager.py | 12 +-- 3 files changed, 139 insertions(+), 73 deletions(-) diff --git a/services/executor_service.py b/services/executor_service.py index 734d8e12..3691a858 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -349,16 +349,12 @@ async def create_executor( connector_name = executor_config.get("connector_name") trading_pair = executor_config.get("trading_pair") - if executor_type == "lp_executor": - # LP executor doesn't need trading_pair for market init - use pool_address for metadata - if not trading_pair: - trading_pair = executor_config.get("pool_address") - if connector_name: - await trading_interface.ensure_connector(connector_name) - else: - # Standard executors need connector and market initialized - if connector_name and trading_pair: + # Ensure connector and market are ready + if connector_name: + if trading_pair: await trading_interface.add_market(connector_name, trading_pair) + else: + await trading_interface.ensure_connector(connector_name) # Set timestamp if not provided (required for time-based features like time_limit) if "timestamp" not in executor_config or executor_config["timestamp"] is None: diff --git a/services/orders_recorder.py b/services/orders_recorder.py index 48d7a77b..04b41bce 100644 --- a/services/orders_recorder.py +++ b/services/orders_recorder.py @@ -21,20 +21,20 @@ class OrdersRecorder: Custom orders recorder that mimics Hummingbot's MarketsRecorder functionality but uses our AsyncDatabaseManager for storage. """ - + def __init__(self, db_manager: AsyncDatabaseManager, account_name: str, connector_name: str): self.db_manager = db_manager self.account_name = account_name self.connector_name = connector_name self._connector: Optional[ConnectorBase] = None - + # Create event forwarders similar to MarketsRecorder self._create_order_forwarder = SourceInfoEventForwarder(self._did_create_order) self._fill_order_forwarder = SourceInfoEventForwarder(self._did_fill_order) self._cancel_order_forwarder = SourceInfoEventForwarder(self._did_cancel_order) self._fail_order_forwarder = SourceInfoEventForwarder(self._did_fail_order) self._complete_order_forwarder = SourceInfoEventForwarder(self._did_complete_order) - + # Event pairs mapping events to forwarders self._event_pairs = [ (MarketEvent.BuyOrderCreated, self._create_order_forwarder), @@ -45,14 +45,12 @@ def __init__(self, db_manager: AsyncDatabaseManager, account_name: str, connecto (MarketEvent.BuyOrderCompleted, self._complete_order_forwarder), (MarketEvent.SellOrderCompleted, self._complete_order_forwarder), ] - + def start(self, connector: ConnectorBase): """Start recording orders for the given connector""" # Idempotency guard: prevent double-registration of listeners if self._connector is not None: - logger.warning( - f"OrdersRecorder already started for {self.account_name}/{self.connector_name}" - ) + logger.warning(f"OrdersRecorder already started for {self.account_name}/{self.connector_name}, ignoring duplicate start") return self._connector = connector @@ -61,40 +59,37 @@ def start(self, connector: ConnectorBase): for event, forwarder in self._event_pairs: connector.add_listener(event, forwarder) logger.info(f"OrdersRecorder: Added listener for {event} with forwarder {forwarder}") - + # Debug: Check if listeners were actually added if hasattr(connector, '_event_listeners'): listeners = connector._event_listeners.get(event, []) logger.info(f"OrdersRecorder: Event {event} now has {len(listeners)} listeners") for i, listener in enumerate(listeners): logger.info(f"OrdersRecorder: Listener {i}: {listener}") - - logger.info( - f"OrdersRecorder started for {self.account_name}/{self.connector_name} " - f"with {len(self._event_pairs)} event listeners" - ) - + + logger.info(f"OrdersRecorder started for {self.account_name}/{self.connector_name} with {len(self._event_pairs)} event listeners") + # Debug: Print connector info logger.info(f"OrdersRecorder: Connector type: {type(connector)}") logger.info(f"OrdersRecorder: Connector name: {getattr(connector, 'name', 'unknown')}") logger.info(f"OrdersRecorder: Connector ready: {getattr(connector, 'ready', 'unknown')}") - + # Test if forwarders are callable for event, forwarder in self._event_pairs: if callable(forwarder): logger.info(f"OrdersRecorder: Forwarder for {event} is callable") else: logger.error(f"OrdersRecorder: Forwarder for {event} is NOT callable: {type(forwarder)}") - + async def stop(self): """Stop recording orders""" if self._connector: # Remove all event listeners for event, forwarder in self._event_pairs: self._connector.remove_listener(event, forwarder) - + logger.info(f"OrdersRecorder stopped for {self.account_name}/{self.connector_name}") - + def _extract_error_message(self, event) -> str: """Extract error message from various possible event attributes.""" # Try different possible attribute names for error messages @@ -103,10 +98,10 @@ def _extract_error_message(self, event) -> str: error_value = getattr(event, attr_name) if error_value: return str(error_value) - + # If no error message found, create a descriptive one return f"Order failed: {event.__class__.__name__}" - + def _did_create_order(self, event_tag: int, market: ConnectorBase, event: Union[BuyOrderCreatedEvent, SellOrderCreatedEvent]): """Handle order creation events - called by SourceInfoEventForwarder""" logger.info(f"OrdersRecorder: _did_create_order called for order {getattr(event, 'order_id', 'unknown')}") @@ -117,64 +112,61 @@ def _did_create_order(self, event_tag: int, market: ConnectorBase, event: Union[ asyncio.create_task(self._handle_order_created(event, trade_type)) except Exception as e: logger.error(f"Error in _did_create_order: {e}") - + def _did_fill_order(self, event_tag: int, market: ConnectorBase, event: OrderFilledEvent): """Handle order fill events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_filled(event)) except Exception as e: logger.error(f"Error in _did_fill_order: {e}") - + def _did_cancel_order(self, event_tag: int, market: ConnectorBase, event: Any): """Handle order cancel events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_cancelled(event)) except Exception as e: logger.error(f"Error in _did_cancel_order: {e}") - + def _did_fail_order(self, event_tag: int, market: ConnectorBase, event: Any): """Handle order failure events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_failed(event)) except Exception as e: logger.error(f"Error in _did_fail_order: {e}") - + def _did_complete_order(self, event_tag: int, market: ConnectorBase, event: Any): """Handle order completion events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_completed(event)) except Exception as e: logger.error(f"Error in _did_complete_order: {e}") - + async def _handle_order_created(self, event: Union[BuyOrderCreatedEvent, SellOrderCreatedEvent], trade_type: TradeType): """Handle order creation events""" logger.info(f"OrdersRecorder: _handle_order_created started for order {event.order_id}") try: async with self.db_manager.get_session_context() as session: order_repo = OrderRepository(session) - + # Check if order already exists first existing_order = await order_repo.get_order_by_client_id(event.order_id) if existing_order: logger.info(f"OrdersRecorder: Order {event.order_id} already exists with status {existing_order.status}") - + # Update exchange_order_id if we have it now and it was missing exchange_order_id = getattr(event, 'exchange_order_id', None) if exchange_order_id and not existing_order.exchange_order_id: existing_order.exchange_order_id = exchange_order_id - logger.info( - f"OrdersRecorder: Updated exchange_order_id to {exchange_order_id} " - f"for order {event.order_id}" - ) - + logger.info(f"OrdersRecorder: Updated exchange_order_id to {exchange_order_id} for order {event.order_id}") + # Update status if it's still in PENDING_CREATE or similar early state if existing_order.status in ["PENDING_CREATE", "PENDING", "SUBMITTED"]: existing_order.status = "OPEN" logger.info(f"OrdersRecorder: Updated status to OPEN for order {event.order_id}") - + await session.flush() return - + order_data = { "client_order_id": event.order_id, "account_name": self.account_name, @@ -188,11 +180,11 @@ async def _handle_order_created(self, event: Union[BuyOrderCreatedEvent, SellOrd "exchange_order_id": getattr(event, 'exchange_order_id', None) } await order_repo.create_order(order_data) - + logger.info(f"OrdersRecorder: Successfully recorded order created: {event.order_id}") except Exception as e: logger.error(f"OrdersRecorder: Error recording order created: {e}") - + async def _handle_order_filled(self, event: OrderFilledEvent): """Handle order fill events""" try: @@ -203,7 +195,7 @@ async def _handle_order_filled(self, event: OrderFilledEvent): # Calculate fees trade_fee_paid = 0 trade_fee_currency = None - + if event.trade_fee: try: base_asset, quote_asset = event.trading_pair.split("-") @@ -217,16 +209,34 @@ async def _handle_order_filled(self, event: OrderFilledEvent): trade_fee_paid = float(fee_in_quote) trade_fee_currency = quote_asset except Exception as e: - logger.error(f"Error calculating trade fee: {e}") - trade_fee_paid = 0 - trade_fee_currency = None - + logger.warning(f"Primary fee calculation failed: {e}. Attempting fallback...") + try: + base_asset, quote_asset = event.trading_pair.split("-") + fallback_fee = await self._calculate_fee_fallback( + trade_fee=event.trade_fee, + base_asset=base_asset, + quote_asset=quote_asset, + fill_price=event.price, + order_amount=event.amount, + ) + if fallback_fee is not None: + trade_fee_paid = float(fallback_fee) + trade_fee_currency = quote_asset + logger.info(f"Fallback fee calculation succeeded: {trade_fee_paid} {trade_fee_currency}") + else: + logger.error(f"Fallback fee calculation returned None for {event.order_id}") + trade_fee_paid = 0 + trade_fee_currency = None + except Exception as fallback_err: + logger.error(f"Fallback fee calculation also failed: {fallback_err}") + trade_fee_paid = 0 + trade_fee_currency = None # Update order with fill information (handle potential NaN values like Hummingbot does) try: filled_amount = Decimal(str(event.amount)) average_fill_price = Decimal(str(event.price)) fee_paid_decimal = Decimal(str(trade_fee_paid)) if trade_fee_paid else None - + order = await order_repo.update_order_fill( client_order_id=event.order_id, filled_amount=filled_amount, @@ -237,13 +247,12 @@ async def _handle_order_filled(self, event: OrderFilledEvent): except (ValueError, InvalidOperation) as e: logger.error(f"Error processing order fill for {event.order_id}: {e}, skipping update") return - + # Create trade record using validated values if order: try: # Validate all values before creating trade record - has_valid_ts = event.timestamp and not math.isnan(event.timestamp) - validated_timestamp = event.timestamp if has_valid_ts else time.time() + validated_timestamp = event.timestamp if event.timestamp and not math.isnan(event.timestamp) else time.time() validated_fee = trade_fee_paid if trade_fee_paid and not math.isnan(trade_fee_paid) else 0 # Use exchange_trade_id if available (unique per fill), fallback to generated id @@ -270,11 +279,12 @@ async def _handle_order_filled(self, event: OrderFilledEvent): logger.debug(f"Trade {trade_id} already exists, skipping duplicate") except (ValueError, TypeError) as e: logger.error(f"Error creating trade record for {event.order_id}: {e}") - + logger.error(f"Trade data that failed: timestamp={event.timestamp}, amount={event.amount}, price={event.price}, fee={trade_fee_paid}") + logger.debug(f"Recorded order fill: {event.order_id} - {event.amount} @ {event.price}") except Exception as e: logger.error(f"Error recording order fill: {e}") - + async def _handle_order_cancelled(self, event: Any): """Handle order cancellation events""" try: @@ -284,11 +294,11 @@ async def _handle_order_cancelled(self, event: Any): client_order_id=event.order_id, status="CANCELLED" ) - + logger.debug(f"Recorded order cancelled: {event.order_id}") except Exception as e: logger.error(f"Error recording order cancellation: {e}") - + def _get_order_details_from_connector(self, order_id: str) -> Optional[dict]: """Try to get order details from connector's tracked orders""" try: @@ -306,18 +316,78 @@ def _get_order_details_from_connector(self, order_id: str) -> Optional[dict]: logger.error(f"Error getting order details from connector: {e}") return None + async def _fetch_conversion_rate(self, from_token: str, to_token: str) -> Optional[Decimal]: + """Fetch the conversion rate between two tokens using the connector's REST API. + Tries direct pair first, then inverse pair.""" + if not self._connector: + return None + try: + direct_pair = f"{from_token}-{to_token}" + price = await asyncio.wait_for( + self._connector._get_last_traded_price(trading_pair=direct_pair), + timeout=5.0, + ) + if price and price > 0: + return Decimal(str(price)) + except Exception: + pass + try: + inverse_pair = f"{to_token}-{from_token}" + price = await asyncio.wait_for( + self._connector._get_last_traded_price(trading_pair=inverse_pair), + timeout=5.0, + ) + if price and price > 0: + return Decimal(1) / Decimal(str(price)) + except Exception: + pass + return None + + async def _calculate_fee_fallback( + self, + trade_fee, + base_asset: str, + quote_asset: str, + fill_price: Decimal, + order_amount: Decimal, + ) -> Optional[Decimal]: + """Manually compute the trade fee in quote asset when the primary method fails.""" + fee_amount = Decimal(0) + + # Handle percent component + if trade_fee.percent and trade_fee.percent != Decimal(0): + fee_amount += (fill_price * order_amount) * trade_fee.percent + + # Handle flat_fees component + for flat_fee in trade_fee.flat_fees: + if flat_fee.token == quote_asset: + fee_amount += flat_fee.amount + elif flat_fee.token == base_asset: + fee_amount += flat_fee.amount * fill_price + else: + rate = await self._fetch_conversion_rate(flat_fee.token, quote_asset) + if rate is not None: + fee_amount += flat_fee.amount * rate + else: + logger.error( + f"Could not fetch conversion rate for {flat_fee.token} -> {quote_asset}" + ) + return None + + return fee_amount + async def _handle_order_failed(self, event: Any): """Handle order failure events""" try: async with self.db_manager.get_session_context() as session: order_repo = OrderRepository(session) - + # Check if order exists, if not try to get details from connector's tracked orders existing_order = await order_repo.get_order_by_client_id(event.order_id) if existing_order: # Extract error message from various possible attributes error_msg = self._extract_error_message(event) - + # Update existing order with failure status and error message await order_repo.update_order_status( client_order_id=event.order_id, @@ -330,7 +400,7 @@ async def _handle_order_failed(self, event: Any): order_details = self._get_order_details_from_connector(event.order_id) if order_details: logger.info(f"Retrieved order details from connector for {event.order_id}: {order_details}") - + # Create order record as FAILED with available details if order_details: order_data = { @@ -352,14 +422,14 @@ async def _handle_order_failed(self, event: Any): "account_name": self.account_name, "connector_name": self.connector_name, "trading_pair": "UNKNOWN", - "trade_type": "UNKNOWN", + "trade_type": "UNKNOWN", "order_type": "UNKNOWN", "amount": 0.0, "price": None, "status": "FAILED", "error_message": self._extract_error_message(event) } - + try: await order_repo.create_order(order_data) logger.info(f"Created failed order record for {event.order_id}") @@ -374,10 +444,10 @@ async def _handle_order_failed(self, event: Any): ) else: raise create_error - + except Exception as e: logger.error(f"Error recording order failure: {e}") - + async def _handle_order_completed(self, event: Any): """Handle order completion events""" try: @@ -387,7 +457,7 @@ async def _handle_order_completed(self, event: Any): if order: order.status = "FILLED" order.exchange_order_id = getattr(event, 'exchange_order_id', None) - + logger.debug(f"Recorded order completed: {event.order_id}") except Exception as e: - logger.error(f"Error recording order completion: {e}") + logger.error(f"Error recording order completion: {e}") \ No newline at end of file diff --git a/utils/mqtt_manager.py b/utils/mqtt_manager.py index a0a3888b..3495eadb 100644 --- a/utils/mqtt_manager.py +++ b/utils/mqtt_manager.py @@ -33,7 +33,7 @@ def __init__(self, host: str, port: int, username: str, password: str): # Auto-discovered bots self._discovered_bots: Dict[str, float] = {} # bot_id: last_seen_timestamp - + # Message deduplication tracking self._processed_messages: Dict[str, float] = {} # message_hash: timestamp self._message_ttl = 300 # 5 minutes TTL for processed messages @@ -90,7 +90,7 @@ async def _get_client(self): for topic, qos in self._subscriptions: await client.subscribe(topic, qos=qos) yield client - + # Cleanup on exit self._connected = False @@ -205,14 +205,14 @@ async def _handle_log(self, bot_id: str, data: Any): level = data.get("level_name") or data.get("levelname") or data.get("level", "INFO") message = data.get("msg") or data.get("message", "") timestamp = data.get("timestamp") or data.get("time") or time.time() - + # Create hash for deduplication (bot_id + message + timestamp within 1 second) message_hash = f"{bot_id}:{message}:{int(timestamp)}" elif isinstance(data, str): message = data timestamp = time.time() level = "INFO" - + # Create hash for string messages message_hash = f"{bot_id}:{message}:{int(timestamp)}" else: @@ -270,7 +270,7 @@ async def _handle_events(self, bot_id: str, data: Any): async def _handle_external_event(self, bot_id: str, channel: str, data: Any): """Handle external events.""" - _ = channel.split("/")[-1] # event_type for future use + event_type = channel.split("/")[-1] async def _handle_rpc_response(self, topic: str, message): """Handle RPC responses on hummingbot-api/response/* topics.""" @@ -297,7 +297,7 @@ async def _handle_command_response(self, bot_id: str, channel: str, data: Any): # Extract command from response channel (e.g., response/start/1234567890 or response/history) channel_parts = channel.split("/") if len(channel_parts) >= 2: - _ = channel_parts[1] # command for future use + command = channel_parts[1] async def start(self): """Start the MQTT client.""" From e8397d6432215bb4bb6468e0c554997cef42e78c Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 8 Apr 2026 09:35:36 -0700 Subject: [PATCH 22/34] refactor: remove fee fallback logic from orders_recorder Simplify fee calculation - if primary method fails, just log error and set fee to 0 instead of complex fallback logic. Co-Authored-By: Claude Opus 4.5 --- services/orders_recorder.py | 86 ++----------------------------------- 1 file changed, 3 insertions(+), 83 deletions(-) diff --git a/services/orders_recorder.py b/services/orders_recorder.py index 04b41bce..8ca65fc0 100644 --- a/services/orders_recorder.py +++ b/services/orders_recorder.py @@ -209,28 +209,9 @@ async def _handle_order_filled(self, event: OrderFilledEvent): trade_fee_paid = float(fee_in_quote) trade_fee_currency = quote_asset except Exception as e: - logger.warning(f"Primary fee calculation failed: {e}. Attempting fallback...") - try: - base_asset, quote_asset = event.trading_pair.split("-") - fallback_fee = await self._calculate_fee_fallback( - trade_fee=event.trade_fee, - base_asset=base_asset, - quote_asset=quote_asset, - fill_price=event.price, - order_amount=event.amount, - ) - if fallback_fee is not None: - trade_fee_paid = float(fallback_fee) - trade_fee_currency = quote_asset - logger.info(f"Fallback fee calculation succeeded: {trade_fee_paid} {trade_fee_currency}") - else: - logger.error(f"Fallback fee calculation returned None for {event.order_id}") - trade_fee_paid = 0 - trade_fee_currency = None - except Exception as fallback_err: - logger.error(f"Fallback fee calculation also failed: {fallback_err}") - trade_fee_paid = 0 - trade_fee_currency = None + logger.error(f"Error calculating trade fee: {e}") + trade_fee_paid = 0 + trade_fee_currency = None # Update order with fill information (handle potential NaN values like Hummingbot does) try: filled_amount = Decimal(str(event.amount)) @@ -279,7 +260,6 @@ async def _handle_order_filled(self, event: OrderFilledEvent): logger.debug(f"Trade {trade_id} already exists, skipping duplicate") except (ValueError, TypeError) as e: logger.error(f"Error creating trade record for {event.order_id}: {e}") - logger.error(f"Trade data that failed: timestamp={event.timestamp}, amount={event.amount}, price={event.price}, fee={trade_fee_paid}") logger.debug(f"Recorded order fill: {event.order_id} - {event.amount} @ {event.price}") except Exception as e: @@ -316,66 +296,6 @@ def _get_order_details_from_connector(self, order_id: str) -> Optional[dict]: logger.error(f"Error getting order details from connector: {e}") return None - async def _fetch_conversion_rate(self, from_token: str, to_token: str) -> Optional[Decimal]: - """Fetch the conversion rate between two tokens using the connector's REST API. - Tries direct pair first, then inverse pair.""" - if not self._connector: - return None - try: - direct_pair = f"{from_token}-{to_token}" - price = await asyncio.wait_for( - self._connector._get_last_traded_price(trading_pair=direct_pair), - timeout=5.0, - ) - if price and price > 0: - return Decimal(str(price)) - except Exception: - pass - try: - inverse_pair = f"{to_token}-{from_token}" - price = await asyncio.wait_for( - self._connector._get_last_traded_price(trading_pair=inverse_pair), - timeout=5.0, - ) - if price and price > 0: - return Decimal(1) / Decimal(str(price)) - except Exception: - pass - return None - - async def _calculate_fee_fallback( - self, - trade_fee, - base_asset: str, - quote_asset: str, - fill_price: Decimal, - order_amount: Decimal, - ) -> Optional[Decimal]: - """Manually compute the trade fee in quote asset when the primary method fails.""" - fee_amount = Decimal(0) - - # Handle percent component - if trade_fee.percent and trade_fee.percent != Decimal(0): - fee_amount += (fill_price * order_amount) * trade_fee.percent - - # Handle flat_fees component - for flat_fee in trade_fee.flat_fees: - if flat_fee.token == quote_asset: - fee_amount += flat_fee.amount - elif flat_fee.token == base_asset: - fee_amount += flat_fee.amount * fill_price - else: - rate = await self._fetch_conversion_rate(flat_fee.token, quote_asset) - if rate is not None: - fee_amount += flat_fee.amount * rate - else: - logger.error( - f"Could not fetch conversion rate for {flat_fee.token} -> {quote_asset}" - ) - return None - - return fee_amount - async def _handle_order_failed(self, event: Any): """Handle order failure events""" try: From 81e4183547fec19e5cbb13c541f93e0a1f5dae03 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 8 Apr 2026 09:36:39 -0700 Subject: [PATCH 23/34] revert: remove all changes from orders_recorder.py Keep orders_recorder.py unchanged from main - no fixes needed for this PR. Co-Authored-By: Claude Opus 4.5 --- services/orders_recorder.py | 86 +++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/services/orders_recorder.py b/services/orders_recorder.py index 8ca65fc0..04b41bce 100644 --- a/services/orders_recorder.py +++ b/services/orders_recorder.py @@ -209,9 +209,28 @@ async def _handle_order_filled(self, event: OrderFilledEvent): trade_fee_paid = float(fee_in_quote) trade_fee_currency = quote_asset except Exception as e: - logger.error(f"Error calculating trade fee: {e}") - trade_fee_paid = 0 - trade_fee_currency = None + logger.warning(f"Primary fee calculation failed: {e}. Attempting fallback...") + try: + base_asset, quote_asset = event.trading_pair.split("-") + fallback_fee = await self._calculate_fee_fallback( + trade_fee=event.trade_fee, + base_asset=base_asset, + quote_asset=quote_asset, + fill_price=event.price, + order_amount=event.amount, + ) + if fallback_fee is not None: + trade_fee_paid = float(fallback_fee) + trade_fee_currency = quote_asset + logger.info(f"Fallback fee calculation succeeded: {trade_fee_paid} {trade_fee_currency}") + else: + logger.error(f"Fallback fee calculation returned None for {event.order_id}") + trade_fee_paid = 0 + trade_fee_currency = None + except Exception as fallback_err: + logger.error(f"Fallback fee calculation also failed: {fallback_err}") + trade_fee_paid = 0 + trade_fee_currency = None # Update order with fill information (handle potential NaN values like Hummingbot does) try: filled_amount = Decimal(str(event.amount)) @@ -260,6 +279,7 @@ async def _handle_order_filled(self, event: OrderFilledEvent): logger.debug(f"Trade {trade_id} already exists, skipping duplicate") except (ValueError, TypeError) as e: logger.error(f"Error creating trade record for {event.order_id}: {e}") + logger.error(f"Trade data that failed: timestamp={event.timestamp}, amount={event.amount}, price={event.price}, fee={trade_fee_paid}") logger.debug(f"Recorded order fill: {event.order_id} - {event.amount} @ {event.price}") except Exception as e: @@ -296,6 +316,66 @@ def _get_order_details_from_connector(self, order_id: str) -> Optional[dict]: logger.error(f"Error getting order details from connector: {e}") return None + async def _fetch_conversion_rate(self, from_token: str, to_token: str) -> Optional[Decimal]: + """Fetch the conversion rate between two tokens using the connector's REST API. + Tries direct pair first, then inverse pair.""" + if not self._connector: + return None + try: + direct_pair = f"{from_token}-{to_token}" + price = await asyncio.wait_for( + self._connector._get_last_traded_price(trading_pair=direct_pair), + timeout=5.0, + ) + if price and price > 0: + return Decimal(str(price)) + except Exception: + pass + try: + inverse_pair = f"{to_token}-{from_token}" + price = await asyncio.wait_for( + self._connector._get_last_traded_price(trading_pair=inverse_pair), + timeout=5.0, + ) + if price and price > 0: + return Decimal(1) / Decimal(str(price)) + except Exception: + pass + return None + + async def _calculate_fee_fallback( + self, + trade_fee, + base_asset: str, + quote_asset: str, + fill_price: Decimal, + order_amount: Decimal, + ) -> Optional[Decimal]: + """Manually compute the trade fee in quote asset when the primary method fails.""" + fee_amount = Decimal(0) + + # Handle percent component + if trade_fee.percent and trade_fee.percent != Decimal(0): + fee_amount += (fill_price * order_amount) * trade_fee.percent + + # Handle flat_fees component + for flat_fee in trade_fee.flat_fees: + if flat_fee.token == quote_asset: + fee_amount += flat_fee.amount + elif flat_fee.token == base_asset: + fee_amount += flat_fee.amount * fill_price + else: + rate = await self._fetch_conversion_rate(flat_fee.token, quote_asset) + if rate is not None: + fee_amount += flat_fee.amount * rate + else: + logger.error( + f"Could not fetch conversion rate for {flat_fee.token} -> {quote_asset}" + ) + return None + + return fee_amount + async def _handle_order_failed(self, event: Any): """Handle order failure events""" try: From 65e8c218febce9dd0c0c0ffcebead94492da9518 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 8 Apr 2026 09:37:03 -0700 Subject: [PATCH 24/34] revert: remove all changes from bots_orchestrator.py Keep bots_orchestrator.py unchanged from main - no fixes needed for this PR. Co-Authored-By: Claude Opus 4.5 --- services/bots_orchestrator.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/services/bots_orchestrator.py b/services/bots_orchestrator.py index a16b8f80..85622f14 100644 --- a/services/bots_orchestrator.py +++ b/services/bots_orchestrator.py @@ -1,7 +1,7 @@ import asyncio import logging -import re from typing import Optional +import re import docker @@ -32,7 +32,7 @@ def __init__(self, broker_host, broker_port, broker_username, broker_password): # Active bots tracking self.active_bots = {} self._update_bots_task: Optional[asyncio.Task] = None - + # Track bots that are currently being stopped and archived self.stopping_bots = set() @@ -303,7 +303,7 @@ def get_bot_status(self, bot_name): "general_logs": [], "recently_active": False, } - + # Get data from MQTT manager controller_reports = self.mqtt_manager.get_bot_controller_reports(bot_name) performance = self.determine_controller_performance(controller_reports) @@ -331,17 +331,18 @@ def get_bot_status(self, bot_name): } except Exception as e: return {"status": "error", "error": str(e)} - + def set_bot_stopping(self, bot_name: str): """Mark a bot as currently being stopped and archived.""" self.stopping_bots.add(bot_name) logger.info(f"Marked bot {bot_name} as stopping") - + def clear_bot_stopping(self, bot_name: str): """Clear the stopping status for a bot.""" self.stopping_bots.discard(bot_name) logger.info(f"Cleared stopping status for bot {bot_name}") - + def is_bot_stopping(self, bot_name: str) -> bool: """Check if a bot is currently being stopped.""" return bot_name in self.stopping_bots + From 87ab3934edea1feac36fa0105dba1f1a7a3adc57 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 8 Apr 2026 10:21:15 -0700 Subject: [PATCH 25/34] Filter out DEX providers from connectors list DEX providers (jupiter/router, uniswap/amm, etc.) are accessed via Gateway networks, not as direct connectors. Co-Authored-By: Claude Opus 4.5 --- routers/connectors.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/routers/connectors.py b/routers/connectors.py index 85d7af86..fa897c9a 100644 --- a/routers/connectors.py +++ b/routers/connectors.py @@ -16,9 +16,11 @@ async def available_connectors(): Get a list of all available connectors. Returns: - List of connector names supported by the system + List of connector names supported by the system (excludes DEX providers which use Gateway networks) """ - return list(AllConnectorSettings.get_connector_settings().keys()) + all_connectors = AllConnectorSettings.get_connector_settings().keys() + # Filter out DEX providers (contain '/') - these are accessed via Gateway networks + return [c for c in all_connectors if '/' not in c] @router.get("/{connector_name}/config-map", response_model=Dict[str, dict]) From 816c2bf74783d42265e1cc44f78bc3b3e7e3f797 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 9 Apr 2026 14:01:05 -0700 Subject: [PATCH 26/34] Clean up debug logging in orders_recorder after order event fix Remove verbose debug logging that was added during investigation of order persistence issues. The root cause (event ordering) was fixed in the hummingbot connector. Changes: - Remove verbose listener registration logs from start() - Remove DEBUG prefixed warnings from event handlers - Use appropriate log levels (debug for routine ops, error for failures) - Add session.flush() in _handle_order_completed for immediate persistence Co-Authored-By: Claude Opus 4.5 --- services/orders_recorder.py | 225 +++++++++++++++++++----------------- 1 file changed, 121 insertions(+), 104 deletions(-) diff --git a/services/orders_recorder.py b/services/orders_recorder.py index 04b41bce..6c4c1067 100644 --- a/services/orders_recorder.py +++ b/services/orders_recorder.py @@ -21,20 +21,25 @@ class OrdersRecorder: Custom orders recorder that mimics Hummingbot's MarketsRecorder functionality but uses our AsyncDatabaseManager for storage. """ - + def __init__(self, db_manager: AsyncDatabaseManager, account_name: str, connector_name: str): self.db_manager = db_manager self.account_name = account_name self.connector_name = connector_name self._connector: Optional[ConnectorBase] = None - + # Create event forwarders similar to MarketsRecorder - self._create_order_forwarder = SourceInfoEventForwarder(self._did_create_order) - self._fill_order_forwarder = SourceInfoEventForwarder(self._did_fill_order) - self._cancel_order_forwarder = SourceInfoEventForwarder(self._did_cancel_order) - self._fail_order_forwarder = SourceInfoEventForwarder(self._did_fail_order) - self._complete_order_forwarder = SourceInfoEventForwarder(self._did_complete_order) - + self._create_order_forwarder = SourceInfoEventForwarder( + self._did_create_order) + self._fill_order_forwarder = SourceInfoEventForwarder( + self._did_fill_order) + self._cancel_order_forwarder = SourceInfoEventForwarder( + self._did_cancel_order) + self._fail_order_forwarder = SourceInfoEventForwarder( + self._did_fail_order) + self._complete_order_forwarder = SourceInfoEventForwarder( + self._did_complete_order) + # Event pairs mapping events to forwarders self._event_pairs = [ (MarketEvent.BuyOrderCreated, self._create_order_forwarder), @@ -45,12 +50,13 @@ def __init__(self, db_manager: AsyncDatabaseManager, account_name: str, connecto (MarketEvent.BuyOrderCompleted, self._complete_order_forwarder), (MarketEvent.SellOrderCompleted, self._complete_order_forwarder), ] - + def start(self, connector: ConnectorBase): """Start recording orders for the given connector""" # Idempotency guard: prevent double-registration of listeners if self._connector is not None: - logger.warning(f"OrdersRecorder already started for {self.account_name}/{self.connector_name}, ignoring duplicate start") + logger.warning( + f"OrdersRecorder already started for {self.account_name}/{self.connector_name}, ignoring duplicate start") return self._connector = connector @@ -58,38 +64,20 @@ def start(self, connector: ConnectorBase): # Subscribe to order events using the same pattern as MarketsRecorder for event, forwarder in self._event_pairs: connector.add_listener(event, forwarder) - logger.info(f"OrdersRecorder: Added listener for {event} with forwarder {forwarder}") - - # Debug: Check if listeners were actually added - if hasattr(connector, '_event_listeners'): - listeners = connector._event_listeners.get(event, []) - logger.info(f"OrdersRecorder: Event {event} now has {len(listeners)} listeners") - for i, listener in enumerate(listeners): - logger.info(f"OrdersRecorder: Listener {i}: {listener}") - - logger.info(f"OrdersRecorder started for {self.account_name}/{self.connector_name} with {len(self._event_pairs)} event listeners") - - # Debug: Print connector info - logger.info(f"OrdersRecorder: Connector type: {type(connector)}") - logger.info(f"OrdersRecorder: Connector name: {getattr(connector, 'name', 'unknown')}") - logger.info(f"OrdersRecorder: Connector ready: {getattr(connector, 'ready', 'unknown')}") - - # Test if forwarders are callable - for event, forwarder in self._event_pairs: - if callable(forwarder): - logger.info(f"OrdersRecorder: Forwarder for {event} is callable") - else: - logger.error(f"OrdersRecorder: Forwarder for {event} is NOT callable: {type(forwarder)}") - + + logger.info( + f"OrdersRecorder started for {self.account_name}/{self.connector_name}") + async def stop(self): """Stop recording orders""" if self._connector: # Remove all event listeners for event, forwarder in self._event_pairs: self._connector.remove_listener(event, forwarder) - - logger.info(f"OrdersRecorder stopped for {self.account_name}/{self.connector_name}") - + + logger.info( + f"OrdersRecorder stopped for {self.account_name}/{self.connector_name}") + def _extract_error_message(self, event) -> str: """Extract error message from various possible event attributes.""" # Try different possible attribute names for error messages @@ -98,75 +86,76 @@ def _extract_error_message(self, event) -> str: error_value = getattr(event, attr_name) if error_value: return str(error_value) - + # If no error message found, create a descriptive one return f"Order failed: {event.__class__.__name__}" - + def _did_create_order(self, event_tag: int, market: ConnectorBase, event: Union[BuyOrderCreatedEvent, SellOrderCreatedEvent]): """Handle order creation events - called by SourceInfoEventForwarder""" - logger.info(f"OrdersRecorder: _did_create_order called for order {getattr(event, 'order_id', 'unknown')}") try: - # Determine trade type from event - trade_type = TradeType.BUY if isinstance(event, BuyOrderCreatedEvent) else TradeType.SELL - logger.info(f"OrdersRecorder: Creating task to handle order created - {trade_type} order") + trade_type = TradeType.BUY if isinstance( + event, BuyOrderCreatedEvent) else TradeType.SELL asyncio.create_task(self._handle_order_created(event, trade_type)) except Exception as e: logger.error(f"Error in _did_create_order: {e}") - + def _did_fill_order(self, event_tag: int, market: ConnectorBase, event: OrderFilledEvent): """Handle order fill events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_filled(event)) except Exception as e: logger.error(f"Error in _did_fill_order: {e}") - + def _did_cancel_order(self, event_tag: int, market: ConnectorBase, event: Any): """Handle order cancel events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_cancelled(event)) except Exception as e: logger.error(f"Error in _did_cancel_order: {e}") - + def _did_fail_order(self, event_tag: int, market: ConnectorBase, event: Any): """Handle order failure events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_failed(event)) except Exception as e: logger.error(f"Error in _did_fail_order: {e}") - + def _did_complete_order(self, event_tag: int, market: ConnectorBase, event: Any): """Handle order completion events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_completed(event)) except Exception as e: logger.error(f"Error in _did_complete_order: {e}") - + async def _handle_order_created(self, event: Union[BuyOrderCreatedEvent, SellOrderCreatedEvent], trade_type: TradeType): """Handle order creation events""" - logger.info(f"OrdersRecorder: _handle_order_created started for order {event.order_id}") try: async with self.db_manager.get_session_context() as session: order_repo = OrderRepository(session) - + # Check if order already exists first existing_order = await order_repo.get_order_by_client_id(event.order_id) if existing_order: - logger.info(f"OrdersRecorder: Order {event.order_id} already exists with status {existing_order.status}") - + logger.info( + f"OrdersRecorder: Order {event.order_id} already exists with status {existing_order.status}") + # Update exchange_order_id if we have it now and it was missing - exchange_order_id = getattr(event, 'exchange_order_id', None) + exchange_order_id = getattr( + event, 'exchange_order_id', None) if exchange_order_id and not existing_order.exchange_order_id: existing_order.exchange_order_id = exchange_order_id - logger.info(f"OrdersRecorder: Updated exchange_order_id to {exchange_order_id} for order {event.order_id}") - + logger.info( + f"OrdersRecorder: Updated exchange_order_id to {exchange_order_id} for order {event.order_id}") + # Update status if it's still in PENDING_CREATE or similar early state if existing_order.status in ["PENDING_CREATE", "PENDING", "SUBMITTED"]: existing_order.status = "OPEN" - logger.info(f"OrdersRecorder: Updated status to OPEN for order {event.order_id}") - + logger.info( + f"OrdersRecorder: Updated status to OPEN for order {event.order_id}") + await session.flush() return - + order_data = { "client_order_id": event.order_id, "account_name": self.account_name, @@ -180,11 +169,11 @@ async def _handle_order_created(self, event: Union[BuyOrderCreatedEvent, SellOrd "exchange_order_id": getattr(event, 'exchange_order_id', None) } await order_repo.create_order(order_data) - - logger.info(f"OrdersRecorder: Successfully recorded order created: {event.order_id}") + + logger.debug(f"Recorded order created: {event.order_id}") except Exception as e: - logger.error(f"OrdersRecorder: Error recording order created: {e}") - + logger.error(f"Error recording order created: {e}") + async def _handle_order_filled(self, event: OrderFilledEvent): """Handle order fill events""" try: @@ -195,7 +184,7 @@ async def _handle_order_filled(self, event: OrderFilledEvent): # Calculate fees trade_fee_paid = 0 trade_fee_currency = None - + if event.trade_fee: try: base_asset, quote_asset = event.trading_pair.split("-") @@ -209,9 +198,11 @@ async def _handle_order_filled(self, event: OrderFilledEvent): trade_fee_paid = float(fee_in_quote) trade_fee_currency = quote_asset except Exception as e: - logger.warning(f"Primary fee calculation failed: {e}. Attempting fallback...") + logger.warning( + f"Primary fee calculation failed: {e}. Attempting fallback...") try: - base_asset, quote_asset = event.trading_pair.split("-") + base_asset, quote_asset = event.trading_pair.split( + "-") fallback_fee = await self._calculate_fee_fallback( trade_fee=event.trade_fee, base_asset=base_asset, @@ -222,21 +213,25 @@ async def _handle_order_filled(self, event: OrderFilledEvent): if fallback_fee is not None: trade_fee_paid = float(fallback_fee) trade_fee_currency = quote_asset - logger.info(f"Fallback fee calculation succeeded: {trade_fee_paid} {trade_fee_currency}") + logger.info( + f"Fallback fee calculation succeeded: {trade_fee_paid} {trade_fee_currency}") else: - logger.error(f"Fallback fee calculation returned None for {event.order_id}") + logger.error( + f"Fallback fee calculation returned None for {event.order_id}") trade_fee_paid = 0 trade_fee_currency = None except Exception as fallback_err: - logger.error(f"Fallback fee calculation also failed: {fallback_err}") + logger.error( + f"Fallback fee calculation also failed: {fallback_err}") trade_fee_paid = 0 trade_fee_currency = None # Update order with fill information (handle potential NaN values like Hummingbot does) try: filled_amount = Decimal(str(event.amount)) average_fill_price = Decimal(str(event.price)) - fee_paid_decimal = Decimal(str(trade_fee_paid)) if trade_fee_paid else None - + fee_paid_decimal = Decimal( + str(trade_fee_paid)) if trade_fee_paid else None + order = await order_repo.update_order_fill( client_order_id=event.order_id, filled_amount=filled_amount, @@ -245,18 +240,22 @@ async def _handle_order_filled(self, event: OrderFilledEvent): fee_currency=trade_fee_currency ) except (ValueError, InvalidOperation) as e: - logger.error(f"Error processing order fill for {event.order_id}: {e}, skipping update") + logger.error( + f"Error processing order fill for {event.order_id}: {e}, skipping update") return - + # Create trade record using validated values if order: try: # Validate all values before creating trade record - validated_timestamp = event.timestamp if event.timestamp and not math.isnan(event.timestamp) else time.time() - validated_fee = trade_fee_paid if trade_fee_paid and not math.isnan(trade_fee_paid) else 0 + validated_timestamp = event.timestamp if event.timestamp and not math.isnan( + event.timestamp) else time.time() + validated_fee = trade_fee_paid if trade_fee_paid and not math.isnan( + trade_fee_paid) else 0 # Use exchange_trade_id if available (unique per fill), fallback to generated id - exchange_trade_id = getattr(event, 'exchange_trade_id', None) + exchange_trade_id = getattr( + event, 'exchange_trade_id', None) if exchange_trade_id: trade_id = f"{event.order_id}_{exchange_trade_id}" else: @@ -269,22 +268,29 @@ async def _handle_order_filled(self, event: OrderFilledEvent): "timestamp": datetime.fromtimestamp(validated_timestamp), "trading_pair": event.trading_pair, "trade_type": event.trade_type.name, - "amount": float(filled_amount), # Use validated amount - "price": float(average_fill_price), # Use validated price + # Use validated amount + "amount": float(filled_amount), + # Use validated price + "price": float(average_fill_price), "fee_paid": validated_fee, "fee_currency": trade_fee_currency } result = await trade_repo.create_trade(trade_data) if result is None: - logger.debug(f"Trade {trade_id} already exists, skipping duplicate") + logger.debug( + f"Trade {trade_id} already exists, skipping duplicate") except (ValueError, TypeError) as e: - logger.error(f"Error creating trade record for {event.order_id}: {e}") - logger.error(f"Trade data that failed: timestamp={event.timestamp}, amount={event.amount}, price={event.price}, fee={trade_fee_paid}") - - logger.debug(f"Recorded order fill: {event.order_id} - {event.amount} @ {event.price}") + logger.error( + f"Error creating trade record for {event.order_id}: {e}") + logger.error( + f"Trade data that failed: timestamp={event.timestamp}, " + f"amount={event.amount}, price={event.price}, fee={trade_fee_paid}") + + logger.debug(f"Recorded order fill: {event.order_id}") except Exception as e: - logger.error(f"Error recording order fill: {e}") - + logger.error( + f"Error recording order fill for {event.order_id}: {e}") + async def _handle_order_cancelled(self, event: Any): """Handle order cancellation events""" try: @@ -294,16 +300,17 @@ async def _handle_order_cancelled(self, event: Any): client_order_id=event.order_id, status="CANCELLED" ) - + logger.debug(f"Recorded order cancelled: {event.order_id}") except Exception as e: logger.error(f"Error recording order cancellation: {e}") - + def _get_order_details_from_connector(self, order_id: str) -> Optional[dict]: """Try to get order details from connector's tracked orders""" try: if self._connector and hasattr(self._connector, 'in_flight_orders'): - in_flight_order = self._connector.in_flight_orders.get(order_id) + in_flight_order = self._connector.in_flight_orders.get( + order_id) if in_flight_order: return { "trading_pair": in_flight_order.trading_pair, @@ -324,7 +331,8 @@ async def _fetch_conversion_rate(self, from_token: str, to_token: str) -> Option try: direct_pair = f"{from_token}-{to_token}" price = await asyncio.wait_for( - self._connector._get_last_traded_price(trading_pair=direct_pair), + self._connector._get_last_traded_price( + trading_pair=direct_pair), timeout=5.0, ) if price and price > 0: @@ -334,7 +342,8 @@ async def _fetch_conversion_rate(self, from_token: str, to_token: str) -> Option try: inverse_pair = f"{to_token}-{from_token}" price = await asyncio.wait_for( - self._connector._get_last_traded_price(trading_pair=inverse_pair), + self._connector._get_last_traded_price( + trading_pair=inverse_pair), timeout=5.0, ) if price and price > 0: @@ -381,26 +390,29 @@ async def _handle_order_failed(self, event: Any): try: async with self.db_manager.get_session_context() as session: order_repo = OrderRepository(session) - + # Check if order exists, if not try to get details from connector's tracked orders existing_order = await order_repo.get_order_by_client_id(event.order_id) if existing_order: # Extract error message from various possible attributes error_msg = self._extract_error_message(event) - + # Update existing order with failure status and error message await order_repo.update_order_status( client_order_id=event.order_id, status="FAILED", error_message=error_msg ) - logger.info(f"Updated existing order {event.order_id} to FAILED status") + logger.info( + f"Updated existing order {event.order_id} to FAILED status") else: # Try to get order details from connector's tracked orders - order_details = self._get_order_details_from_connector(event.order_id) + order_details = self._get_order_details_from_connector( + event.order_id) if order_details: - logger.info(f"Retrieved order details from connector for {event.order_id}: {order_details}") - + logger.info( + f"Retrieved order details from connector for {event.order_id}: {order_details}") + # Create order record as FAILED with available details if order_details: order_data = { @@ -422,32 +434,35 @@ async def _handle_order_failed(self, event: Any): "account_name": self.account_name, "connector_name": self.connector_name, "trading_pair": "UNKNOWN", - "trade_type": "UNKNOWN", + "trade_type": "UNKNOWN", "order_type": "UNKNOWN", "amount": 0.0, "price": None, "status": "FAILED", "error_message": self._extract_error_message(event) } - + try: await order_repo.create_order(order_data) - logger.info(f"Created failed order record for {event.order_id}") + logger.info( + f"Created failed order record for {event.order_id}") except Exception as create_error: # If creation fails due to duplicate key, try to update existing order if "duplicate key" in str(create_error).lower() or "unique constraint" in str(create_error).lower(): - logger.info(f"Order {event.order_id} already exists, updating status to FAILED") + logger.info( + f"Order {event.order_id} already exists, updating status to FAILED") await order_repo.update_order_status( client_order_id=event.order_id, status="FAILED", - error_message=self._extract_error_message(event) + error_message=self._extract_error_message( + event) ) else: raise create_error - + except Exception as e: logger.error(f"Error recording order failure: {e}") - + async def _handle_order_completed(self, event: Any): """Handle order completion events""" try: @@ -456,8 +471,10 @@ async def _handle_order_completed(self, event: Any): order = await order_repo.get_order_by_client_id(event.order_id) if order: order.status = "FILLED" - order.exchange_order_id = getattr(event, 'exchange_order_id', None) - + order.exchange_order_id = getattr( + event, 'exchange_order_id', None) + await session.flush() + logger.debug(f"Recorded order completed: {event.order_id}") except Exception as e: - logger.error(f"Error recording order completion: {e}") \ No newline at end of file + logger.error(f"Error recording order completion: {e}") From 531a156d3bef5f0c0e0cdab5be3583edb44312a3 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 9 Apr 2026 14:03:48 -0700 Subject: [PATCH 27/34] Improvements from prior sessions - lp_rebalancer.py: Make trading_pair and pool_address required fields - models/executors.py: Add trading_pair to LP executor example - executor_service.py: Add side field to executor response - trading_service.py: Update balances after adding new trading pair Co-Authored-By: Claude Opus 4.5 --- .../generic/lp_rebalancer/lp_rebalancer.py | 6 +++--- models/executors.py | 1 + services/executor_service.py | 6 ++++++ services/trading_service.py | 14 +++++++++++--- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index 5ff49fbd..9a4a17cc 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -40,9 +40,9 @@ class LPRebalancerConfig(ControllerConfigBase): # Examples: "meteora/clmm", "orca/clmm", "raydium/clmm" lp_provider: str = "orca/clmm" - # Pool configuration - trading_pair: str = "" - pool_address: str = "" + # Pool configuration (required) + trading_pair: str + pool_address: str # Position parameters total_amount_quote: Decimal = Field(default=Decimal("50"), json_schema_extra={"is_updatable": True}) diff --git a/models/executors.py b/models/executors.py index eadc0015..561404d8 100644 --- a/models/executors.py +++ b/models/executors.py @@ -253,6 +253,7 @@ class CreateExecutorRequest(BaseModel): "type": "lp_executor", "connector_name": "solana-mainnet-beta", "lp_provider": "meteora/clmm", + "trading_pair": "SOL-USDC", "pool_address": "HTvjzsfX3yU6BUodCjZ5vZkUrAxMDTrBs3CJaq43ashR", "lower_price": "80", "upper_price": "100", diff --git a/services/executor_service.py b/services/executor_service.py index 3691a858..8acded43 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -635,6 +635,12 @@ def _format_executor_info( result["close_type"] = executor.close_type.name if executor.close_type else None result["is_active"] = not executor.is_closed + # Add side from executor_info (it's a property, not serialized by model_dump) + side = executor_info.side + if side is not None: + # Convert TradeType enum or int to string + result["side"] = side.name if hasattr(side, 'name') else str(side) + # For grid executors, filter out heavy fields from custom_info if executor_type == "grid_executor" and result.get("custom_info"): heavy_fields = {"levels_by_state", "filled_orders", "failed_orders", "canceled_orders"} diff --git a/services/trading_service.py b/services/trading_service.py index b3dac601..4e648725 100644 --- a/services/trading_service.py +++ b/services/trading_service.py @@ -7,14 +7,14 @@ import logging import time from decimal import Decimal -from typing import Dict, List, Optional, Set, TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, List, Optional, Set from hummingbot.connector.connector_base import ConnectorBase -from hummingbot.core.data_type.common import OrderType, TradeType, PositionAction +from hummingbot.core.data_type.common import OrderType, PositionAction, TradeType if TYPE_CHECKING: - from services.unified_connector_service import UnifiedConnectorService from services.market_data_service import MarketDataService + from services.unified_connector_service import UnifiedConnectorService logger = logging.getLogger(__name__) @@ -173,6 +173,14 @@ async def add_market( # Register trading pair with connector self._register_trading_pair_with_connector(connector, trading_pair) + # Update balances to include tokens from new trading pair + if hasattr(connector, '_update_balances'): + try: + await connector._update_balances() + logger.debug(f"Updated balances for {connector_name} after adding {trading_pair}") + except Exception as e: + logger.warning(f"Failed to update balances for {connector_name}: {e}") + logger.info(f"Market {connector_name}/{trading_pair} added to trading interface") async def remove_market( From 6294cc20e80c92c51e785c116ed3fa05ea9ee8bb Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 9 Apr 2026 14:17:34 -0700 Subject: [PATCH 28/34] Change side from int to TradeType enum, rename BOTH to RANGE - lp_rebalancer now uses TradeType enum (BUY, SELL, RANGE) - RANGE (value=3) replaces BOTH (was 0) for 50/50 split positions - side=0 now throws error instead of silently mapping to RANGE - Validator accepts: TradeType enum, string ("BUY", "SELL", "RANGE"), or int (1, 2, 3) - Updated API example to use "BUY" string format This aligns LP executor side handling with Order executor pattern. Co-Authored-By: Claude Opus 4.5 --- .../generic/lp_rebalancer/lp_rebalancer.py | 109 ++++++++++-------- models/executors.py | 2 +- 2 files changed, 63 insertions(+), 48 deletions(-) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index 9a4a17cc..5ae6e0f9 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -46,7 +46,7 @@ class LPRebalancerConfig(ControllerConfigBase): # Position parameters total_amount_quote: Decimal = Field(default=Decimal("50"), json_schema_extra={"is_updatable": True}) - side: int = Field(default=1, json_schema_extra={"is_updatable": True}) # 0=BOTH, 1=BUY, 2=SELL + side: TradeType = Field(default=TradeType.BUY, json_schema_extra={"is_updatable": True}) # BUY, SELL, or RANGE position_width_pct: Decimal = Field(default=Decimal("0.5"), json_schema_extra={"is_updatable": True}) position_offset_pct: Decimal = Field( default=Decimal("0.01"), @@ -97,11 +97,27 @@ def validate_price_limits(cls, v): @field_validator("side", mode="before") @classmethod def validate_side(cls, v): - """Validate side is 0, 1, or 2.""" - v = int(v) - if v not in (0, 1, 2): - raise ValueError("side must be 0 (BOTH), 1 (BUY), or 2 (SELL)") - return v + """Validate and convert side to TradeType enum.""" + if isinstance(v, TradeType): + return v + if isinstance(v, str): + v = v.upper() + if v in ("BUY", "1"): + return TradeType.BUY + elif v in ("SELL", "2"): + return TradeType.SELL + elif v in ("RANGE", "3"): + return TradeType.RANGE + raise ValueError(f"Invalid side '{v}'. Must be BUY, SELL, or RANGE") + if isinstance(v, int): + if v == 1: + return TradeType.BUY + elif v == 2: + return TradeType.SELL + elif v == 3: + return TradeType.RANGE + raise ValueError(f"Invalid side {v}. Must be 1 (BUY), 2 (SELL), or 3 (RANGE)") + raise ValueError(f"Invalid side type {type(v)}. Must be TradeType, str, or int") @model_validator(mode="after") def validate_price_limit_ranges(self): @@ -240,7 +256,7 @@ def is_swap_executor_done(self) -> bool: return True return swap_executor.is_done - def _check_autoswap_needed(self, side: int, current_price: Decimal) -> Optional[OrderExecutorConfig]: + def _check_autoswap_needed(self, side: TradeType, current_price: Decimal) -> Optional[OrderExecutorConfig]: """ Check if autoswap is needed and return swap config if so. @@ -358,10 +374,10 @@ def _check_autoswap_needed(self, side: int, current_price: Decimal) -> Optional[ return None elif base_deficit > 0 and quote_deficit > 0: - # Both tokens in deficit - user is underfunded for side=0 (BOTH) + # Both tokens in deficit - user is underfunded for side=RANGE total_deficit_quote = base_deficit * current_price + quote_deficit self.logger().warning( - f"Autoswap: cannot swap - both tokens in deficit (side=0). " + f"Autoswap: cannot swap - both tokens in deficit (side=RANGE). " f"Need {base_deficit:.6f} more {self._base_token} AND {quote_deficit:.6f} more {self._quote_token} " f"(total deficit: {total_deficit_quote:.2f} {self._quote_token})" ) @@ -495,7 +511,7 @@ def determine_executor_actions(self) -> List[ExecutorAction]: self._pending_rebalance = False self._pending_rebalance_side = None elif not self._initial_position_created: - # Initial position: use configured side (can be 0=BOTH, 1=BUY, 2=SELL) + # Initial position: use configured side (can be BUY, SELL, or RANGE) side = self.config.side else: # After initial position but no pending rebalance (e.g., position failed/closed) @@ -590,9 +606,9 @@ def _handle_rebalance(self, executor: ExecutorInfo) -> Optional[StopExecutorActi # Step 1: Determine side from price direction (using [lower, upper) convention) if current_price >= upper_price: - new_side = 1 # BUY - price at or above range + new_side = TradeType.BUY # price at or above range elif current_price < lower_price: - new_side = 2 # SELL - price below range + new_side = TradeType.SELL # price below range else: # Price is in range, shouldn't happen in OUT_OF_RANGE state self.logger().warning(f"Price {current_price} appears in range [{lower_price}, {upper_price})") @@ -641,7 +657,7 @@ def _is_beyond_rebalance_threshold(self, executor: ExecutorInfo) -> bool: return False # Price is in range - def _create_executor_config(self, side: int) -> Optional[LPExecutorConfig]: + def _create_executor_config(self, side: TradeType) -> Optional[LPExecutorConfig]: """ Create executor config for the given side. @@ -671,12 +687,12 @@ def _create_executor_config(self, side: int) -> Optional[LPExecutorConfig]: # Check if bounds were clamped by price limits clamped = [] - if side == 1: # BUY + if side == TradeType.BUY: # BUY if self.config.buy_price_max and upper_price == self.config.buy_price_max: clamped.append(f"upper=buy_price_max({self.config.buy_price_max})") if self.config.buy_price_min and lower_price == self.config.buy_price_min: clamped.append(f"lower=buy_price_min({self.config.buy_price_min})") - elif side == 2: # SELL + elif side == TradeType.SELL: # SELL if self.config.sell_price_min and lower_price == self.config.sell_price_min: clamped.append(f"lower=sell_price_min({self.config.sell_price_min})") if self.config.sell_price_max and upper_price == self.config.sell_price_max: @@ -704,12 +720,12 @@ def _create_executor_config(self, side: int) -> Optional[LPExecutorConfig]: extra_params=extra_params if extra_params else None, ) - def _calculate_amounts(self, side: int, current_price: Decimal) -> tuple: + def _calculate_amounts(self, side: TradeType, current_price: Decimal) -> tuple: """ Calculate base and quote amounts based on side, offset, and total_amount_quote. Allocation logic: - - Side 0 (BOTH): split 50/50 + - Side RANGE: split 50/50 - Side 1/2 with offset >= 0 (out-of-range): 100% single-sided - Side 1/2 with offset < 0 (in-range): proportional split based on price position @@ -722,12 +738,12 @@ def _calculate_amounts(self, side: int, current_price: Decimal) -> tuple: total = self.config.total_amount_quote offset = self.config.position_offset_pct - if side == 0: # BOTH + if side == TradeType.RANGE: # RANGE quote_amt = total / Decimal("2") base_amt = quote_amt / current_price elif offset >= 0: # Out-of-range: single-sided allocation - if side == 1: # BUY - all quote + if side == TradeType.BUY: # BUY - all quote base_amt = Decimal("0") quote_amt = total else: # SELL - all base @@ -741,7 +757,7 @@ def _calculate_amounts(self, side: int, current_price: Decimal) -> tuple: if price_range <= 0 or current_price <= lower_price: # At or below lower bound - all quote for BUY, all base for SELL - if side == 1: + if side == TradeType.BUY: base_amt = Decimal("0") quote_amt = total else: @@ -749,7 +765,7 @@ def _calculate_amounts(self, side: int, current_price: Decimal) -> tuple: quote_amt = Decimal("0") elif current_price >= upper_price: # At or above upper bound - all base for SELL, all quote for BUY - if side == 2: + if side == TradeType.SELL: base_amt = total / current_price quote_amt = Decimal("0") else: @@ -768,11 +784,11 @@ def _calculate_amounts(self, side: int, current_price: Decimal) -> tuple: return base_amt, quote_amt - def _calculate_price_bounds(self, side: int, current_price: Decimal) -> tuple: + def _calculate_price_bounds(self, side: TradeType, current_price: Decimal) -> tuple: """ Calculate position bounds based on side and price limits. - Side 0 (BOTH): centered on current price, clamped to [buy_min, sell_max] + Side RANGE: centered on current price, clamped to [buy_min, sell_max] Side 1 (BUY): upper = min(current, buy_price_max) * (1 - offset), lower extends width below Side 2 (SELL): lower = max(current, sell_price_min) * (1 + offset), upper extends width above @@ -782,7 +798,7 @@ def _calculate_price_bounds(self, side: int, current_price: Decimal) -> tuple: width = self.config.position_width_pct / Decimal("100") offset = self.config.position_offset_pct / Decimal("100") - if side == 0: # BOTH + if side == TradeType.RANGE: # RANGE half_width = width / Decimal("2") lower_price = current_price * (Decimal("1") - half_width) upper_price = current_price * (Decimal("1") + half_width) @@ -792,7 +808,7 @@ def _calculate_price_bounds(self, side: int, current_price: Decimal) -> tuple: if self.config.sell_price_max: upper_price = min(upper_price, self.config.sell_price_max) - elif side == 1: # BUY + elif side == TradeType.BUY: # BUY # Position BELOW current price so we only need quote token (USDC) if self.config.buy_price_max: upper_price = min(current_price, self.config.buy_price_max) @@ -820,28 +836,28 @@ def _calculate_price_bounds(self, side: int, current_price: Decimal) -> tuple: return lower_price, upper_price - def _is_price_within_limits(self, price: Decimal, side: int) -> bool: + def _is_price_within_limits(self, price: Decimal, side: TradeType) -> bool: """ Check if price is within configured limits for the position type. Price must be within the range to create a position that's IN_RANGE: - BUY: price must be within [buy_price_min, buy_price_max] - SELL: price must be within [sell_price_min, sell_price_max] - - BOTH: price must be within the intersection of both ranges + - RANGE: price must be within the intersection of both ranges If price is outside the range, the position would be immediately OUT_OF_RANGE. """ - if side == 2: # SELL + if side == TradeType.SELL: # SELL if self.config.sell_price_min and price < self.config.sell_price_min: return False if self.config.sell_price_max and price > self.config.sell_price_max: return False - elif side == 1: # BUY + elif side == TradeType.BUY: # BUY if self.config.buy_price_min and price < self.config.buy_price_min: return False if self.config.buy_price_max and price > self.config.buy_price_max: return False - else: # BOTH - must be within intersection of ranges + else: # RANGE - must be within intersection of ranges # Check buy range if self.config.buy_price_min and price < self.config.buy_price_min: return False @@ -854,13 +870,13 @@ def _is_price_within_limits(self, price: Decimal, side: int) -> bool: return False return True - def _determine_side_from_price(self, current_price: Decimal) -> int: + def _determine_side_from_price(self, current_price: Decimal) -> TradeType: """ - Determine side (1=BUY or 2=SELL) based on current price vs price limits. + Determine side (BUY or SELL) based on current price vs price limits. - Used after initial position to ensure we never use side=0 (BOTH) for rebalances. - - If price is closer to buy range, use BUY (1) - - If price is closer to sell range, use SELL (2) + Used after initial position to ensure we never use RANGE for rebalances. + - If price is closer to buy range, use BUY + - If price is closer to sell range, use SELL """ # Get midpoints of buy and sell ranges buy_mid = None @@ -874,21 +890,21 @@ def _determine_side_from_price(self, current_price: Decimal) -> int: # If both ranges defined, use the one price is closer to if buy_mid and sell_mid: if current_price <= buy_mid: - return 1 # BUY - price in lower range + return TradeType.BUY # price in lower range elif current_price >= sell_mid: - return 2 # SELL - price in upper range + return TradeType.SELL # price in upper range else: # Price between buy_mid and sell_mid - use BUY if closer to buy_mid - return 1 if (current_price - buy_mid) < (sell_mid - current_price) else 2 + return TradeType.BUY if (current_price - buy_mid) < (sell_mid - current_price) else TradeType.SELL # If only one range defined, use that side if buy_mid: - return 1 + return TradeType.BUY if sell_mid: - return 2 + return TradeType.SELL # No price limits defined - default to BUY - return 1 + return TradeType.BUY async def update_processed_data(self): """Called every tick - fetch pool price.""" @@ -925,8 +941,7 @@ def to_format_status(self) -> List[str]: status.append(line + " " * (box_width - len(line) + 1) + "|") # Config summary - side_names = {0: "BOTH", 1: "BUY", 2: "SELL"} - side_str = side_names.get(self.config.side, '?') + side_str = self.config.side.name amt = self.config.total_amount_quote width = self.config.position_width_pct offset = self.config.position_offset_pct @@ -1121,9 +1136,9 @@ def to_format_status(self) -> List[str]: closed_swaps = [e for e in closed if getattr(e.config, "type", None) == "order_executor"] # Count LP positions by side - both_count = len([e for e in closed_lp if getattr(e.config, "side", None) == 0]) - buy_count = len([e for e in closed_lp if getattr(e.config, "side", None) == 1]) - sell_count = len([e for e in closed_lp if getattr(e.config, "side", None) == 2]) + range_count = len([e for e in closed_lp if getattr(e.config, "side", None) == TradeType.RANGE]) + buy_count = len([e for e in closed_lp if getattr(e.config, "side", None) == TradeType.BUY]) + sell_count = len([e for e in closed_lp if getattr(e.config, "side", None) == TradeType.SELL]) # Calculate fees from closed LP positions total_fees_base = Decimal("0") @@ -1136,7 +1151,7 @@ def to_format_status(self) -> List[str]: pool_price = self._pool_price or Decimal("0") total_fees_value = total_fees_base * pool_price + total_fees_quote - line = f"| Closed Positions: {len(closed_lp)} (both:{both_count} buy:{buy_count} sell:{sell_count})" + line = f"| Closed Positions: {len(closed_lp)} (range:{range_count} buy:{buy_count} sell:{sell_count})" status.append(line + " " * (box_width - len(line) + 1) + "|") # Show swaps count if any diff --git a/models/executors.py b/models/executors.py index 561404d8..7cfa326e 100644 --- a/models/executors.py +++ b/models/executors.py @@ -259,7 +259,7 @@ class CreateExecutorRequest(BaseModel): "upper_price": "100", "base_amount": "0", "quote_amount": "10.0", - "side": 1, + "side": "BUY", "extra_params": {"strategyType": 0}, "keep_position": False } From e0fca6c92c0b779b88fe90e511d8e05f36e49253 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 9 Apr 2026 14:22:21 -0700 Subject: [PATCH 29/34] Simplify price limit validation with clamp-or-switch logic - Remove verbose clamping detection/logging - Add _validate_and_clamp_bounds function: - If both bounds within limits: proceed - If one bound exceeds: clamp it - If both bounds exceed: try opposite side - Remove clamping from _calculate_price_bounds (now handled centrally) - Keep anchoring logic (BUY anchors at buy_price_max, SELL at sell_price_min) Co-Authored-By: Claude Opus 4.5 --- .../generic/lp_rebalancer/lp_rebalancer.py | 126 ++++++++++++------ 1 file changed, 85 insertions(+), 41 deletions(-) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index 5ae6e0f9..17e8e889 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -669,41 +669,33 @@ def _create_executor_config(self, side: TradeType) -> Optional[LPExecutorConfig] self.logger().warning("No pool price available - waiting for update_processed_data") return None - # Calculate amounts based on side - base_amt, quote_amt = self._calculate_amounts(side, current_price) - - # Calculate bounds + # Calculate bounds for requested side lower_price, upper_price = self._calculate_price_bounds(side, current_price) - # Validate bounds + # Check bounds against price limits - clamp if one exceeds, try opposite if both exceed + lower_price, upper_price, side = self._validate_and_clamp_bounds( + lower_price, upper_price, side, current_price + ) + if lower_price is None: + return None + + # Validate bounds after clamping if lower_price >= upper_price: - self.logger().warning(f"Invalid bounds [{lower_price}, {upper_price}] - skipping position") + self.logger().warning(f"Invalid bounds [{lower_price}, {upper_price}] - skipping") return None + # Calculate amounts based on final side + base_amt, quote_amt = self._calculate_amounts(side, current_price) + # Build extra params (connector-specific) extra_params = {} if self.config.strategy_type is not None: extra_params["strategyType"] = self.config.strategy_type - # Check if bounds were clamped by price limits - clamped = [] - if side == TradeType.BUY: # BUY - if self.config.buy_price_max and upper_price == self.config.buy_price_max: - clamped.append(f"upper=buy_price_max({self.config.buy_price_max})") - if self.config.buy_price_min and lower_price == self.config.buy_price_min: - clamped.append(f"lower=buy_price_min({self.config.buy_price_min})") - elif side == TradeType.SELL: # SELL - if self.config.sell_price_min and lower_price == self.config.sell_price_min: - clamped.append(f"lower=sell_price_min({self.config.sell_price_min})") - if self.config.sell_price_max and upper_price == self.config.sell_price_max: - clamped.append(f"upper=sell_price_max({self.config.sell_price_max})") - - clamped_info = f", clamped: {', '.join(clamped)}" if clamped else "" - offset_pct = self.config.position_offset_pct self.logger().info( - f"Creating position: side={side}, pool_price={current_price:.2f}, " - f"bounds=[{lower_price:.4f}, {upper_price:.4f}], offset_pct={offset_pct}, " - f"base={base_amt:.4f}, quote={quote_amt:.4f}{clamped_info}" + f"Creating position: side={side.name}, pool_price={current_price:.2f}, " + f"bounds=[{lower_price:.4f}, {upper_price:.4f}], " + f"base={base_amt:.4f}, quote={quote_amt:.4f}" ) return LPExecutorConfig( @@ -798,41 +790,29 @@ def _calculate_price_bounds(self, side: TradeType, current_price: Decimal) -> tu width = self.config.position_width_pct / Decimal("100") offset = self.config.position_offset_pct / Decimal("100") - if side == TradeType.RANGE: # RANGE + if side == TradeType.RANGE: + # Centered on current price half_width = width / Decimal("2") lower_price = current_price * (Decimal("1") - half_width) upper_price = current_price * (Decimal("1") + half_width) - # Clamp to limits - if self.config.buy_price_min: - lower_price = max(lower_price, self.config.buy_price_min) - if self.config.sell_price_max: - upper_price = min(upper_price, self.config.sell_price_max) - elif side == TradeType.BUY: # BUY - # Position BELOW current price so we only need quote token (USDC) + elif side == TradeType.BUY: + # Anchor at buy_price_max if set, otherwise at current price if self.config.buy_price_max: upper_price = min(current_price, self.config.buy_price_max) else: upper_price = current_price - # Apply offset to decrease upper bound (ensures out-of-range) upper_price = upper_price * (Decimal("1") - offset) lower_price = upper_price * (Decimal("1") - width) - # Clamp lower to floor - if self.config.buy_price_min: - lower_price = max(lower_price, self.config.buy_price_min) else: # SELL - # Position ABOVE current price so we only need base token (SOL) + # Anchor at sell_price_min if set, otherwise at current price if self.config.sell_price_min: lower_price = max(current_price, self.config.sell_price_min) else: lower_price = current_price - # Apply offset to increase lower bound (ensures out-of-range) lower_price = lower_price * (Decimal("1") + offset) upper_price = lower_price * (Decimal("1") + width) - # Clamp upper to ceiling - if self.config.sell_price_max: - upper_price = min(upper_price, self.config.sell_price_max) return lower_price, upper_price @@ -870,6 +850,70 @@ def _is_price_within_limits(self, price: Decimal, side: TradeType) -> bool: return False return True + def _validate_and_clamp_bounds( + self, lower_price: Decimal, upper_price: Decimal, side: TradeType, current_price: Decimal + ) -> tuple: + """ + Validate bounds against price limits. Clamp if one bound exceeds, try opposite side if both exceed. + + Returns: (lower_price, upper_price, side) or (None, None, None) if no valid position possible. + """ + # Get limits for this side + if side == TradeType.BUY: + min_limit = self.config.buy_price_min + max_limit = self.config.buy_price_max + else: # SELL + min_limit = self.config.sell_price_min + max_limit = self.config.sell_price_max + + # Check how many bounds exceed limits + lower_exceeds = min_limit and lower_price < min_limit + upper_exceeds = max_limit and upper_price > max_limit + + if not lower_exceeds and not upper_exceeds: + # Both bounds within limits + return lower_price, upper_price, side + + if lower_exceeds and upper_exceeds: + # Both bounds exceed - try opposite side + opposite_side = TradeType.SELL if side == TradeType.BUY else TradeType.BUY + opp_lower, opp_upper = self._calculate_price_bounds(opposite_side, current_price) + + # Check opposite side limits + if opposite_side == TradeType.BUY: + opp_min = self.config.buy_price_min + opp_max = self.config.buy_price_max + else: + opp_min = self.config.sell_price_min + opp_max = self.config.sell_price_max + + opp_lower_exceeds = opp_min and opp_lower < opp_min + opp_upper_exceeds = opp_max and opp_upper > opp_max + + if not opp_lower_exceeds and not opp_upper_exceeds: + self.logger().info(f"Side {side.name} out of limits, using {opposite_side.name}") + return opp_lower, opp_upper, opposite_side + elif opp_lower_exceeds and not opp_upper_exceeds: + # Clamp lower on opposite side + self.logger().info(f"Side {side.name} out of limits, using {opposite_side.name} (clamped lower)") + return opp_min, opp_upper, opposite_side + elif not opp_lower_exceeds and opp_upper_exceeds: + # Clamp upper on opposite side + self.logger().info(f"Side {side.name} out of limits, using {opposite_side.name} (clamped upper)") + return opp_lower, opp_max, opposite_side + else: + # Both sides completely out of limits + self.logger().info("Both sides out of price limits - waiting") + return None, None, None + + # Only one bound exceeds - clamp it + if lower_exceeds: + self.logger().debug(f"Clamping lower from {lower_price} to {min_limit}") + return min_limit, upper_price, side + else: # upper_exceeds + self.logger().debug(f"Clamping upper from {upper_price} to {max_limit}") + return lower_price, max_limit, side + def _determine_side_from_price(self, current_price: Decimal) -> TradeType: """ Determine side (BUY or SELL) based on current price vs price limits. From 58938d6d3d4964cbe832e4fd4d881f5d8b492dbe Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 9 Apr 2026 14:24:59 -0700 Subject: [PATCH 30/34] Skip price limit checks for RANGE positions RANGE (side=3) positions now bypass _validate_and_clamp_bounds entirely. Only BUY and SELL positions are validated against their respective price limits. Co-Authored-By: Claude Opus 4.5 --- bots/controllers/generic/lp_rebalancer/lp_rebalancer.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index 17e8e889..e043ebb7 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -857,7 +857,13 @@ def _validate_and_clamp_bounds( Validate bounds against price limits. Clamp if one bound exceeds, try opposite side if both exceed. Returns: (lower_price, upper_price, side) or (None, None, None) if no valid position possible. + + Note: RANGE positions skip price limit checks entirely. """ + # RANGE positions skip price limit checks + if side == TradeType.RANGE: + return lower_price, upper_price, side + # Get limits for this side if side == TradeType.BUY: min_limit = self.config.buy_price_min From 442de669f9eb62a3f6787edb78680c159e0fba7c Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 9 Apr 2026 15:00:31 -0700 Subject: [PATCH 31/34] Sync lp_rebalancer.py from hummingbot - Skip price limit checks for RANGE positions - Use TradeType enum for side parameter Co-Authored-By: Claude Opus 4.5 --- .../generic/lp_rebalancer/lp_rebalancer.py | 643 ++++++++---------- 1 file changed, 265 insertions(+), 378 deletions(-) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index e043ebb7..b111abb5 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -2,6 +2,8 @@ from decimal import Decimal from typing import List, Optional +from pydantic import Field, field_validator, model_validator + from hummingbot.core.data_type.common import MarketDict, TradeType from hummingbot.core.utils.async_utils import safe_ensure_future from hummingbot.data_feed.candles_feed.data_types import CandlesConfig @@ -9,25 +11,31 @@ from hummingbot.strategy_v2.controllers import ControllerBase, ControllerConfigBase from hummingbot.strategy_v2.executors.data_types import ConnectorPair from hummingbot.strategy_v2.executors.gateway_utils import parse_provider -from hummingbot.strategy_v2.executors.lp_executor.data_types import LPExecutorConfig, LPExecutorStates +from hummingbot.strategy_v2.executors.lp_executor.data_types import LPExecutorConfig from hummingbot.strategy_v2.executors.order_executor.data_types import ExecutionStrategy, OrderExecutorConfig -from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction, StopExecutorAction +from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction from hummingbot.strategy_v2.models.executors import CloseType from hummingbot.strategy_v2.models.executors_info import ExecutorInfo -from pydantic import Field, field_validator, model_validator class LPRebalancerConfig(ControllerConfigBase): """ Configuration for LP Rebalancer Controller. - Uses total_amount_quote and side for position sizing. - Implements KEEP vs REBALANCE logic based on price limits. + This controller uses LP executor's upper_limit_price/lower_limit_price feature + to automatically close positions when price exceeds thresholds, eliminating + manual rebalancing monitoring. + + Key features: + - No rebalance_seconds timer - uses rebalance_threshold_pct to set executor limit prices + - LP executor auto-closes when price exceeds limits + - Controller just monitors for completion and re-opens if within price bounds + - Uses keep_position=True - controller handles position tracking via position_hold Provider Architecture: - connector_name: The network identifier (e.g., "solana-mainnet-beta") - lp_provider: LP provider in format "dex/trading_type" (e.g., "meteora/clmm") - - autoswap: Uses OrderExecutor with network's configured swapProvider + - autoswap uses network's configured swapProvider (via Gateway) """ controller_type: str = "generic" controller_name: str = "lp_rebalancer" @@ -51,19 +59,18 @@ class LPRebalancerConfig(ControllerConfigBase): position_offset_pct: Decimal = Field( default=Decimal("0.01"), json_schema_extra={"is_updatable": True}, - description="Offset from current price. Positive = out-of-range (single-sided). " - "Negative = in-range (needs both tokens, autoswap will convert |offset|%)" + description="Offset from current price. Positive = out-of-range (single-sided). Negative = in-range (needs both tokens, autoswap will convert |offset|%)" ) - # Rebalancing - rebalance_seconds: int = Field(default=60, json_schema_extra={"is_updatable": True}) + # Rebalance threshold - used to set LP executor's limit prices + # When price moves this % beyond position bounds, executor auto-closes rebalance_threshold_pct: Decimal = Field( - default=Decimal("0.1"), + default=Decimal("1"), json_schema_extra={"is_updatable": True}, - description="Price must be this % out of range before rebalance timer starts (e.g., 0.1 = 0.1%, 2 = 2%)" + description="Price threshold % beyond position bounds that triggers auto-close (e.g., 1 = 1%)" ) - # Price limits - overlapping grids for sell and buy ranges + # Price limits - controller-level limits for deciding whether to re-open # Sell range: [sell_price_min, sell_price_max] # Buy range: [buy_price_min, buy_price_max] sell_price_max: Optional[Decimal] = Field(default=None, json_schema_extra={"is_updatable": True}) @@ -78,7 +85,7 @@ class LPRebalancerConfig(ControllerConfigBase): autoswap: bool = Field( default=False, json_schema_extra={"is_updatable": True}, - description="Automatically swap tokens if balance is insufficient for position." + description="Automatically swap tokens if balance is insufficient for position. Uses network's swapProvider." ) swap_buffer_pct: Decimal = Field( default=Decimal("0.01"), @@ -145,13 +152,13 @@ def update_markets(self, markets: MarketDict) -> MarketDict: class LPRebalancer(ControllerBase): """ - Controller for LP position management with smart rebalancing. + Controller for LP position management using executor-level auto-close. Key features: - - Uses total_amount_quote for all positions (initial and rebalance) - - Derives rebalance side from price vs last executor's range - - KEEP position when already at limit, REBALANCE when not - - Validates bounds before creating positions + - Uses LP executor's upper_limit_price/lower_limit_price for auto-closing + - No manual rebalancing timer - executor handles position close + - Controller monitors for completion and re-opens within price limits + - Uses keep_position=True for position tracking via position_hold """ _logger: Optional[HummingbotLogger] = None @@ -176,30 +183,31 @@ def __init__(self, config: LPRebalancerConfig, *args, **kwargs): self._base_token: str = parts[0] if len(parts) >= 2 else "" self._quote_token: str = parts[1] if len(parts) >= 2 else "" - # Rebalance tracking - self._pending_rebalance: bool = False - self._pending_rebalance_side: Optional[int] = None # Side for pending rebalance - # Track the executor we created self._current_executor_id: Optional[str] = None - # Track amounts from last closed position (for rebalance sizing) + # Track amounts from last closed position (for autoswap sizing) self._last_closed_base_amount: Optional[Decimal] = None self._last_closed_quote_amount: Optional[Decimal] = None self._last_closed_base_fee: Optional[Decimal] = None self._last_closed_quote_fee: Optional[Decimal] = None - # Track initial balances for comparison + # Track initial balances for comparison (wallet balance at controller start) self._initial_base_balance: Optional[Decimal] = None self._initial_quote_balance: Optional[Decimal] = None + # Position hold: cumulative net position from closed LP executors + # Tracks net change = (returned + fees) - initial_deposited + self._position_hold_base: Decimal = Decimal("0") + self._position_hold_quote: Decimal = Decimal("0") + # Flag to trigger balance update after position creation self._pending_balance_update: bool = False # Cached pool price (updated in update_processed_data) self._pool_price: Optional[Decimal] = None - # Swap executor tracking (for autoswap feature) + # Order executor tracking (for autoswap feature) self._swap_executor_id: Optional[str] = None self._pending_swap_side: Optional[int] = None # LP side to create after swap completes @@ -215,8 +223,9 @@ def __init__(self, config: LPRebalancerConfig, *args, **kwargs): ]) def active_executor(self) -> Optional[ExecutorInfo]: - """Get current active executor (should be 0 or 1)""" - active = [e for e in self.executors_info if e.is_active] + """Get current active LP executor (should be 0 or 1)""" + active = [e for e in self.executors_info + if e.is_active and getattr(e.config, "type", None) == "lp_executor"] return active[0] if active else None def get_tracked_executor(self) -> Optional[ExecutorInfo]: @@ -239,7 +248,7 @@ def is_tracked_executor_terminated(self) -> bool: return executor.status == RunnableStatus.TERMINATED def get_swap_executor(self) -> Optional[ExecutorInfo]: - """Get the swap executor we're tracking""" + """Get the order executor we're tracking for autoswap""" if not self._swap_executor_id: return None for e in self.executors_info: @@ -248,7 +257,7 @@ def get_swap_executor(self) -> Optional[ExecutorInfo]: return None def is_swap_executor_done(self) -> bool: - """Check if swap executor has completed (success or failure)""" + """Check if order executor has completed (success or failure)""" if not self._swap_executor_id: return True swap_executor = self.get_swap_executor() @@ -256,29 +265,23 @@ def is_swap_executor_done(self) -> bool: return True return swap_executor.is_done - def _check_autoswap_needed(self, side: TradeType, current_price: Decimal) -> Optional[OrderExecutorConfig]: + def _check_autoswap_needed(self, side: int, current_price: Decimal) -> Optional[OrderExecutorConfig]: """ - Check if autoswap is needed and return swap config if so. + Check if autoswap is needed and return order config if so. Returns OrderExecutorConfig if swap is needed, None otherwise. - - Simply checks balance vs required amounts and swaps deficit + buffer if insufficient. - Works for both positive offset (out-of-range) and negative offset (in-range) positions. - - For rebalances, includes tokens from just-closed position in available balance - since wallet balance may not be updated yet. + Uses network's configured swapProvider via Gateway connector. """ if not self.config.autoswap: return None # Capture closed position amounts BEFORE creating LP position - # (they get cleared after position creation in determine_executor_actions) closed_base = self._last_closed_base_amount or Decimal("0") closed_quote = self._last_closed_quote_amount or Decimal("0") closed_base_fee = self._last_closed_base_fee or Decimal("0") closed_quote_fee = self._last_closed_quote_fee or Decimal("0") - # Calculate required amounts (handles negative offset internally) + # Calculate required amounts base_amt, quote_amt = self._calculate_amounts(side, current_price) # Get current wallet balances @@ -294,7 +297,6 @@ def _check_autoswap_needed(self, side: TradeType, current_price: Decimal) -> Opt return None # For rebalances, add closed position amounts to available balance - # (wallet balance may not be updated yet after position close) if closed_base > 0 or closed_quote > 0: base_balance += closed_base + closed_base_fee quote_balance += closed_quote + closed_quote_fee @@ -327,13 +329,11 @@ def _check_autoswap_needed(self, side: TradeType, current_price: Decimal) -> Opt if base_deficit > 0 and quote_deficit <= 0: # Need more base, have enough quote - BUY base with quote swap_amount = base_deficit * buffer_multiplier - # Check if we have enough quote to buy this much base - required_quote = swap_amount * current_price * Decimal("1.02") # 2% extra for price movement + required_quote = swap_amount * current_price * Decimal("1.02") if quote_balance >= required_quote: self.logger().info( f"Autoswap: BUY {swap_amount:.6f} {self._base_token} " - f"(deficit={base_deficit:.6f} + {self.config.swap_buffer_pct}% buffer, " - f"have {quote_balance:.6f} {self._quote_token})" + f"(deficit={base_deficit:.6f} + {self.config.swap_buffer_pct}% buffer)" ) return OrderExecutorConfig( timestamp=self.market_data_provider.time(), @@ -345,19 +345,16 @@ def _check_autoswap_needed(self, side: TradeType, current_price: Decimal) -> Opt ) else: self.logger().warning( - f"Autoswap: insufficient quote ({quote_balance:.6f}) to buy {swap_amount:.6f} base " - f"(need ~{required_quote:.6f} {self._quote_token})" + f"Autoswap: insufficient quote ({quote_balance:.6f}) to buy {swap_amount:.6f} base" ) return None elif quote_deficit > 0 and base_deficit <= 0: # Need more quote, have enough base - SELL base for quote swap_amount = (quote_deficit / current_price) * buffer_multiplier - # Check if we have enough base to sell - if base_balance >= swap_amount * Decimal("1.02"): # 2% extra for price movement + if base_balance >= swap_amount * Decimal("1.02"): self.logger().info( - f"Autoswap: SELL {swap_amount:.6f} {self._base_token} for ~{quote_deficit:.6f} {self._quote_token} " - f"(deficit + {self.config.swap_buffer_pct}% buffer, have {base_balance:.6f} {self._base_token})" + f"Autoswap: SELL {swap_amount:.6f} {self._base_token} for ~{quote_deficit:.6f} {self._quote_token}" ) return OrderExecutorConfig( timestamp=self.market_data_provider.time(), @@ -374,12 +371,10 @@ def _check_autoswap_needed(self, side: TradeType, current_price: Decimal) -> Opt return None elif base_deficit > 0 and quote_deficit > 0: - # Both tokens in deficit - user is underfunded for side=RANGE total_deficit_quote = base_deficit * current_price + quote_deficit self.logger().warning( f"Autoswap: cannot swap - both tokens in deficit (side=RANGE). " - f"Need {base_deficit:.6f} more {self._base_token} AND {quote_deficit:.6f} more {self._quote_token} " - f"(total deficit: {total_deficit_quote:.2f} {self._quote_token})" + f"Total deficit: {total_deficit_quote:.2f} {self._quote_token}" ) return None @@ -397,7 +392,14 @@ def _trigger_balance_update(self): self.logger().debug(f"Could not trigger balance update: {e}") def determine_executor_actions(self) -> List[ExecutorAction]: - """Decide whether to create/stop executors""" + """ + Decide whether to create executors. + + Simplified logic: + - No active executor: check if we should create one (price within limits) + - Active executor: just wait for it to auto-close via limit prices + - No manual OUT_OF_RANGE monitoring or timer logic needed + """ # Capture initial balances on first run if self._initial_base_balance is None: try: @@ -412,29 +414,26 @@ def determine_executor_actions(self) -> List[ExecutorAction]: actions = [] - # Check if swap executor is running (autoswap in progress) + # Handle order executor tracking and completion (for autoswap) if self._pending_swap_side is not None: - # Find and track the swap executor if not already tracked if not self._swap_executor_id: for e in self.executors_info: if e.config.type == "order_executor" and e.is_active: self._swap_executor_id = e.id - self.logger().info(f"Tracking swap executor: {e.id}") + self.logger().info(f"Tracking order executor for swap: {e.id}") break - # If swap is pending but executor not found yet, wait for it to appear if not self._swap_executor_id: - self.logger().debug("Waiting for swap executor to appear in executors_info") + self.logger().debug("Waiting for order executor to appear in executors_info") return actions if self._swap_executor_id: if not self.is_swap_executor_done(): swap_executor = self.get_swap_executor() - state = swap_executor.custom_info.get("state") if swap_executor else "unknown" - self.logger().debug(f"Waiting for swap executor to complete (state: {state})") + self.logger().debug("Waiting for order executor to complete swap") return actions - # Swap executor completed - check result and proceed + # Order executor completed swap_executor = self.get_swap_executor() pending_side = self._pending_swap_side @@ -442,14 +441,35 @@ def determine_executor_actions(self) -> List[ExecutorAction]: self._swap_executor_id = None self._pending_swap_side = None - # Check if swap succeeded (not failed) + # Check if swap succeeded (not FAILED close type) swap_succeeded = swap_executor and swap_executor.close_type != CloseType.FAILED if swap_succeeded: self.logger().info("Autoswap completed successfully, proceeding to LP position") - # Trigger balance update after successful swap self._trigger_balance_update() - # Create LP position with the side that was pending + # Update position_hold with swap's inventory change + if swap_executor: + custom = swap_executor.custom_info + swap_side = custom.get("side") # TradeType enum or string + swap_side_str = swap_side.name if hasattr(swap_side, 'name') else str(swap_side) + executed_amount = Decimal(str(custom.get("executed_amount_base", 0))) + executed_price = Decimal(str(custom.get("average_executed_price", 0))) + quote_amount = executed_amount * executed_price + + if swap_side_str == "BUY": + # BUY swap: gained base, spent quote + self._position_hold_base += executed_amount + self._position_hold_quote -= quote_amount + else: + # SELL swap: spent base, gained quote + self._position_hold_base -= executed_amount + self._position_hold_quote += quote_amount + + self.logger().info( + f"Swap {swap_side_str} {executed_amount:.6f} {self._base_token} @ {executed_price:.4f}. " + f"Position hold: base={self._position_hold_base:+.6f}, quote={self._position_hold_quote:+.6f}" + ) + if pending_side is not None: executor_config = self._create_executor_config(pending_side) if executor_config: @@ -460,14 +480,10 @@ def determine_executor_actions(self) -> List[ExecutorAction]: self._initial_position_created = True self._pending_balance_update = True else: - # Swap failed - log error and skip LP position creation this cycle close_type = swap_executor.close_type if swap_executor else "unknown" self.logger().error( - f"Autoswap FAILED (close_type: {close_type}). " - f"Will retry autoswap check on next cycle for side={pending_side}" + f"Autoswap FAILED (close_type: {close_type}). Will retry on next cycle." ) - # Don't create LP position - let the next cycle re-check balances - # and potentially retry the swap return actions @@ -488,39 +504,89 @@ def determine_executor_actions(self) -> List[ExecutorAction]: ) return actions - # Previous executor terminated - capture final amounts for rebalance sizing + # Previous executor terminated - capture final amounts and update position_hold terminated_executor = self.get_tracked_executor() - if terminated_executor and self._pending_rebalance: - self._last_closed_base_amount = Decimal(str(terminated_executor.custom_info.get("base_amount", 0))) - self._last_closed_quote_amount = Decimal(str(terminated_executor.custom_info.get("quote_amount", 0))) - self._last_closed_base_fee = Decimal(str(terminated_executor.custom_info.get("base_fee", 0))) - self._last_closed_quote_fee = Decimal(str(terminated_executor.custom_info.get("quote_fee", 0))) - self.logger().info( - f"Captured closed position amounts: base={self._last_closed_base_amount}, " - f"quote={self._last_closed_quote_amount}, base_fee={self._last_closed_base_fee}, " - f"quote_fee={self._last_closed_quote_fee}" - ) + if terminated_executor: + # Skip position_hold update if executor failed (no tokens were actually deposited/returned) + if terminated_executor.close_type == CloseType.FAILED: + self.logger().warning( + f"Executor {terminated_executor.id} FAILED - skipping position_hold update" + ) + else: + self._last_closed_base_amount = Decimal(str(terminated_executor.custom_info.get("base_amount", 0))) + self._last_closed_quote_amount = Decimal(str(terminated_executor.custom_info.get("quote_amount", 0))) + self._last_closed_base_fee = Decimal(str(terminated_executor.custom_info.get("base_fee", 0))) + self._last_closed_quote_fee = Decimal(str(terminated_executor.custom_info.get("quote_fee", 0))) + + # Get initial amounts deposited + initial_base = Decimal(str(terminated_executor.custom_info.get("initial_base_amount", 0))) + initial_quote = Decimal(str(terminated_executor.custom_info.get("initial_quote_amount", 0))) + + # Update position_hold with NET change from this executor + # Net = (returned + fees) - initial_deposited + base_net = (self._last_closed_base_amount + self._last_closed_base_fee) - initial_base + quote_net = (self._last_closed_quote_amount + self._last_closed_quote_fee) - initial_quote + self._position_hold_base += base_net + self._position_hold_quote += quote_net + + self.logger().info( + f"Executor completed. Initial: base={initial_base}, quote={initial_quote}. " + f"Returned: base={self._last_closed_base_amount}+{self._last_closed_base_fee}, " + f"quote={self._last_closed_quote_amount}+{self._last_closed_quote_fee}. " + f"Net change: base={base_net:+}, quote={quote_net:+}. " + f"Position hold total: base={self._position_hold_base}, quote={self._position_hold_quote}" + ) + + # Check if executor FAILED - retry with same side from executor's config + executor_failed = terminated_executor and terminated_executor.close_type == CloseType.FAILED + failed_executor_side = None + if executor_failed: + failed_executor_side = terminated_executor.custom_info.get("side") + + # Capture closed position bounds for side determination (only for successful closes) + closed_lower_price = None + closed_upper_price = None + if terminated_executor and not executor_failed: + closed_lower_price = Decimal(str(terminated_executor.custom_info.get("lower_price", 0))) + closed_upper_price = Decimal(str(terminated_executor.custom_info.get("upper_price", 0))) # Clear tracking self._current_executor_id = None # Determine side for new position - if self._pending_rebalance and self._pending_rebalance_side is not None: - # Rebalance: use the side determined by price direction - side = self._pending_rebalance_side - self._pending_rebalance = False - self._pending_rebalance_side = None + if executor_failed and failed_executor_side is not None: + # Retry with same side on failure + side = failed_executor_side + self.logger().info(f"Retrying with same side={side} after executor failure") elif not self._initial_position_created: - # Initial position: use configured side (can be BUY, SELL, or RANGE) + # Initial position: use configured side side = self.config.side + elif closed_lower_price and closed_upper_price and self._pool_price: + # After position close: determine side from price direction relative to closed bounds + # If price >= upper_price: price went UP → BUY (use USDC we got) + # If price < lower_price: price went DOWN → SELL (use SOL we got) + if self._pool_price >= closed_upper_price: + side = TradeType.BUY # price above range + self.logger().info(f"Price {self._pool_price} >= upper {closed_upper_price} → side=BUY") + elif self._pool_price < closed_lower_price: + side = TradeType.SELL # price below range + self.logger().info(f"Price {self._pool_price} < lower {closed_lower_price} → side=SELL") + else: + # Price is within old bounds (shouldn't happen with limit-price auto-close) + side = self._determine_side_from_price(self._pool_price) + self.logger().info(f"Price {self._pool_price} in range [{closed_lower_price}, {closed_upper_price}] → side={side} from limits") else: - # After initial position but no pending rebalance (e.g., position failed/closed) - # Determine side from current price vs price limits + # Fallback to price limits if not self._pool_price: self.logger().info("Waiting for pool price to determine side") return actions side = self._determine_side_from_price(self._pool_price) + # Check if price is within limits before creating position + if self._pool_price and not self._is_price_within_limits(self._pool_price, side): + self.logger().debug(f"Price {self._pool_price} outside limits for side={side}, waiting") + return actions + # Check if autoswap is needed before creating LP position if self.config.autoswap: if not self._pool_price: @@ -528,18 +594,16 @@ def determine_executor_actions(self) -> List[ExecutorAction]: return actions swap_config = self._check_autoswap_needed(side, self._pool_price) if swap_config: - # Create swap executor and wait for it to complete self._pending_swap_side = side actions.append(CreateExecutorAction( controller_id=self.config.id, executor_config=swap_config )) - # Track the swap executor ID on next tick return actions else: self.logger().info("Autoswap: no swap needed, balances sufficient") - # Create executor config with calculated bounds + # Create executor config with limit prices executor_config = self._create_executor_config(side) if executor_config is None: self.logger().warning("Skipping position creation - invalid bounds") @@ -549,7 +613,7 @@ def determine_executor_actions(self) -> List[ExecutorAction]: controller_id=self.config.id, executor_config=executor_config )) - # Note: _initial_position_created is set below when position is confirmed active + self._initial_position_created = True self._pending_balance_update = True # Clear closed position amounts after LP position is created @@ -560,116 +624,28 @@ def determine_executor_actions(self) -> List[ExecutorAction]: return actions - # Mark initial position created and trigger balance update when position is active + # Active executor exists - trigger balance update when position becomes active if self._pending_balance_update: state = executor.custom_info.get("state") if state in ("IN_RANGE", "OUT_OF_RANGE"): self._pending_balance_update = False - self._initial_position_created = True # Only mark created when actually active self._trigger_balance_update() - # Check executor state - state = executor.custom_info.get("state") - - # Don't take action while executor is in transition states - if state in [LPExecutorStates.OPENING.value, LPExecutorStates.CLOSING.value]: - return actions - - # Check for rebalancing when out of range - if state == LPExecutorStates.OUT_OF_RANGE.value: - # Check if price is beyond threshold before considering timer - if self._is_beyond_rebalance_threshold(executor): - out_of_range_seconds = executor.custom_info.get("out_of_range_seconds") - if out_of_range_seconds is not None and out_of_range_seconds >= self.config.rebalance_seconds: - rebalance_action = self._handle_rebalance(executor) - if rebalance_action: - actions.append(rebalance_action) - + # No action needed - executor will auto-close via limit prices return actions - def _handle_rebalance(self, executor: ExecutorInfo) -> Optional[StopExecutorAction]: - """ - Handle rebalancing logic. - - Returns StopExecutorAction if rebalance needed, None if KEEP. - """ - current_price = executor.custom_info.get("current_price") - lower_price = executor.custom_info.get("lower_price") - upper_price = executor.custom_info.get("upper_price") - - if current_price is None or lower_price is None or upper_price is None: - return None - - current_price = Decimal(str(current_price)) - lower_price = Decimal(str(lower_price)) - upper_price = Decimal(str(upper_price)) - - # Step 1: Determine side from price direction (using [lower, upper) convention) - if current_price >= upper_price: - new_side = TradeType.BUY # price at or above range - elif current_price < lower_price: - new_side = TradeType.SELL # price below range - else: - # Price is in range, shouldn't happen in OUT_OF_RANGE state - self.logger().warning(f"Price {current_price} appears in range [{lower_price}, {upper_price})") - return None - - # Step 2: Check if new position would be valid (price within limits) - if not self._is_price_within_limits(current_price, new_side): - # Don't log repeatedly - this is checked every tick - return None - - # Step 3: Initiate rebalance - self._pending_rebalance = True - self._pending_rebalance_side = new_side - self.logger().info( - f"REBALANCE initiated (side={new_side}, price={current_price}, " - f"old_bounds=[{lower_price}, {upper_price}])" - ) - - return StopExecutorAction( - controller_id=self.config.id, - executor_id=executor.id, - ) - - def _is_beyond_rebalance_threshold(self, executor: ExecutorInfo) -> bool: - """ - Check if price is beyond the rebalance threshold. - - Price must be this % out of range before rebalance timer is considered. - """ - current_price = executor.custom_info.get("current_price") - lower_price = executor.custom_info.get("lower_price") - upper_price = executor.custom_info.get("upper_price") - - if current_price is None or lower_price is None or upper_price is None: - return False - - threshold = self.config.rebalance_threshold_pct / Decimal("100") - - # Check if price is beyond threshold above upper or below lower - if current_price > upper_price: - deviation_pct = (current_price - upper_price) / upper_price - return deviation_pct >= threshold - elif current_price < lower_price: - deviation_pct = (lower_price - current_price) / lower_price - return deviation_pct >= threshold - - return False # Price is in range - def _create_executor_config(self, side: TradeType) -> Optional[LPExecutorConfig]: """ - Create executor config for the given side. + Create executor config with limit prices for auto-close. - Returns None if bounds are invalid. + Sets upper_limit_price and lower_limit_price based on rebalance_threshold_pct. """ - # Use pool price (fetched in update_processed_data every tick) current_price = self._pool_price if current_price is None or current_price == 0: self.logger().warning("No pool price available - waiting for update_processed_data") return None - # Calculate bounds for requested side + # Calculate position bounds for requested side lower_price, upper_price = self._calculate_price_bounds(side, current_price) # Check bounds against price limits - clamp if one exceeds, try opposite if both exceed @@ -687,15 +663,21 @@ def _create_executor_config(self, side: TradeType) -> Optional[LPExecutorConfig] # Calculate amounts based on final side base_amt, quote_amt = self._calculate_amounts(side, current_price) + # Calculate limit prices for auto-close + threshold = self.config.rebalance_threshold_pct / Decimal("100") + upper_limit_price = upper_price * (Decimal("1") + threshold) + lower_limit_price = lower_price * (Decimal("1") - threshold) + # Build extra params (connector-specific) extra_params = {} if self.config.strategy_type is not None: extra_params["strategyType"] = self.config.strategy_type self.logger().info( - f"Creating position: side={side.name}, pool_price={current_price:.2f}, " - f"bounds=[{lower_price:.4f}, {upper_price:.4f}], " - f"base={base_amt:.4f}, quote={quote_amt:.4f}" + f"Creating position: side={side.name}, pool_price={current_price:.6f}, " + f"bounds=[{lower_price:.6f}, {upper_price:.6f}], " + f"limits=[{lower_limit_price:.6f}, {upper_limit_price:.6f}], " + f"base={base_amt:.6f}, quote={quote_amt:.6f}" ) return LPExecutorConfig( @@ -710,27 +692,21 @@ def _create_executor_config(self, side: TradeType) -> Optional[LPExecutorConfig] quote_amount=quote_amt, side=side, extra_params=extra_params if extra_params else None, + # Key difference: set limit prices for auto-close + upper_limit_price=upper_limit_price, + lower_limit_price=lower_limit_price, + # Use keep_position=True - controller handles position tracking + keep_position=True, ) def _calculate_amounts(self, side: TradeType, current_price: Decimal) -> tuple: """ Calculate base and quote amounts based on side, offset, and total_amount_quote. - - Allocation logic: - - Side RANGE: split 50/50 - - Side 1/2 with offset >= 0 (out-of-range): 100% single-sided - - Side 1/2 with offset < 0 (in-range): proportional split based on price position - - For in-range positions, the split is calculated based on where current price - sits in the range. This mirrors CLMM behavior where both tokens are needed - when price is within bounds. - - Note: No clamping is done here - autoswap handles any token deficits. """ total = self.config.total_amount_quote offset = self.config.position_offset_pct - if side == TradeType.RANGE: # RANGE + if side == TradeType.RANGE: quote_amt = total / Decimal("2") base_amt = quote_amt / current_price elif offset >= 0: @@ -742,13 +718,11 @@ def _calculate_amounts(self, side: TradeType, current_price: Decimal) -> tuple: base_amt = total / current_price quote_amt = Decimal("0") else: - # In-range (offset < 0): proportional split based on price position in range - # Calculate bounds to determine where price sits + # In-range (offset < 0): proportional split lower_price, upper_price = self._calculate_price_bounds(side, current_price) price_range = upper_price - lower_price if price_range <= 0 or current_price <= lower_price: - # At or below lower bound - all quote for BUY, all base for SELL if side == TradeType.BUY: base_amt = Decimal("0") quote_amt = total @@ -756,7 +730,6 @@ def _calculate_amounts(self, side: TradeType, current_price: Decimal) -> tuple: base_amt = total / current_price quote_amt = Decimal("0") elif current_price >= upper_price: - # At or above upper bound - all base for SELL, all quote for BUY if side == TradeType.SELL: base_amt = total / current_price quote_amt = Decimal("0") @@ -764,13 +737,9 @@ def _calculate_amounts(self, side: TradeType, current_price: Decimal) -> tuple: base_amt = Decimal("0") quote_amt = total else: - # Price is in range - calculate proportional split - # price_ratio: 0 at lower_price, 1 at upper_price price_ratio = (current_price - lower_price) / price_range - # As price goes up, more of the position is in quote, less in base quote_pct = price_ratio base_pct = Decimal("1") - price_ratio - quote_amt = total * quote_pct base_amt = (total * base_pct) / current_price @@ -779,13 +748,6 @@ def _calculate_amounts(self, side: TradeType, current_price: Decimal) -> tuple: def _calculate_price_bounds(self, side: TradeType, current_price: Decimal) -> tuple: """ Calculate position bounds based on side and price limits. - - Side RANGE: centered on current price, clamped to [buy_min, sell_max] - Side 1 (BUY): upper = min(current, buy_price_max) * (1 - offset), lower extends width below - Side 2 (SELL): lower = max(current, sell_price_min) * (1 + offset), upper extends width above - - The offset ensures single-sided positions start out-of-range so they only - require one token (SOL for SELL, USDC for BUY). """ width = self.config.position_width_pct / Decimal("100") offset = self.config.position_offset_pct / Decimal("100") @@ -819,31 +781,22 @@ def _calculate_price_bounds(self, side: TradeType, current_price: Decimal) -> tu def _is_price_within_limits(self, price: Decimal, side: TradeType) -> bool: """ Check if price is within configured limits for the position type. - - Price must be within the range to create a position that's IN_RANGE: - - BUY: price must be within [buy_price_min, buy_price_max] - - SELL: price must be within [sell_price_min, sell_price_max] - - RANGE: price must be within the intersection of both ranges - - If price is outside the range, the position would be immediately OUT_OF_RANGE. """ - if side == TradeType.SELL: # SELL + if side == TradeType.SELL: if self.config.sell_price_min and price < self.config.sell_price_min: return False if self.config.sell_price_max and price > self.config.sell_price_max: return False - elif side == TradeType.BUY: # BUY + elif side == TradeType.BUY: if self.config.buy_price_min and price < self.config.buy_price_min: return False if self.config.buy_price_max and price > self.config.buy_price_max: return False - else: # RANGE - must be within intersection of ranges - # Check buy range + else: # RANGE if self.config.buy_price_min and price < self.config.buy_price_min: return False if self.config.buy_price_max and price > self.config.buy_price_max: return False - # Check sell range if self.config.sell_price_min and price < self.config.sell_price_min: return False if self.config.sell_price_max and price > self.config.sell_price_max: @@ -923,12 +876,7 @@ def _validate_and_clamp_bounds( def _determine_side_from_price(self, current_price: Decimal) -> TradeType: """ Determine side (BUY or SELL) based on current price vs price limits. - - Used after initial position to ensure we never use RANGE for rebalances. - - If price is closer to buy range, use BUY - - If price is closer to sell range, use SELL """ - # Get midpoints of buy and sell ranges buy_mid = None sell_mid = None @@ -937,24 +885,20 @@ def _determine_side_from_price(self, current_price: Decimal) -> TradeType: if self.config.sell_price_min and self.config.sell_price_max: sell_mid = (self.config.sell_price_min + self.config.sell_price_max) / 2 - # If both ranges defined, use the one price is closer to if buy_mid and sell_mid: if current_price <= buy_mid: - return TradeType.BUY # price in lower range + return TradeType.BUY elif current_price >= sell_mid: - return TradeType.SELL # price in upper range + return TradeType.SELL else: - # Price between buy_mid and sell_mid - use BUY if closer to buy_mid return TradeType.BUY if (current_price - buy_mid) < (sell_mid - current_price) else TradeType.SELL - # If only one range defined, use that side if buy_mid: return TradeType.BUY if sell_mid: return TradeType.SELL - # No price limits defined - default to BUY - return TradeType.BUY + return TradeType.BUY # Default to BUY async def update_processed_data(self): """Called every tick - fetch pool price.""" @@ -975,7 +919,7 @@ def to_format_status(self) -> List[str]: """Format status for display.""" status = [] box_width = 100 - price_decimals = 8 # For small-value tokens like memecoins + price_decimals = 6 # Header status.append("+" + "-" * box_width + "+") @@ -983,41 +927,35 @@ def to_format_status(self) -> List[str]: status.append(header + " " * (box_width - len(header) + 1) + "|") status.append("+" + "-" * box_width + "+") - # === CONFIG SECTION === + # Config summary line = f"| Network: {self.config.connector_name} | LP: {self.config.lp_provider}" status.append(line + " " * (box_width - len(line) + 1) + "|") line = f"| Pool: {self.config.pool_address}" status.append(line + " " * (box_width - len(line) + 1) + "|") - # Config summary side_str = self.config.side.name amt = self.config.total_amount_quote width = self.config.position_width_pct offset = self.config.position_offset_pct - rebal = self.config.rebalance_seconds - line = f"| Config: side={side_str}, amount={amt} {self._quote_token}, width={width}%, offset={offset}%, rebal={rebal}s" + threshold = self.config.rebalance_threshold_pct + line = f"| Config: side={side_str}, amount={amt} {self._quote_token}, width={width}%, offset={offset}%, threshold={threshold}%" status.append(line + " " * (box_width - len(line) + 1) + "|") - # Spacer before Position section status.append("|" + " " * box_width + "|") - # === POSITION SECTION === + # Position section executor = self.active_executor() or self.get_tracked_executor() - - # Get position amounts for balance calculations pos_base_amount = Decimal("0") pos_quote_amount = Decimal("0") if executor and not executor.is_done: custom = executor.custom_info - # Position Address position_address = custom.get("position_address", "N/A") line = f"| Position: {position_address}" status.append(line + " " * (box_width - len(line) + 1) + "|") - # Assets row: base_amount + quote_amount = total value pos_base_amount = Decimal(str(custom.get("base_amount", 0))) pos_quote_amount = Decimal(str(custom.get("quote_amount", 0))) total_value_quote = Decimal(str(custom.get("total_value_quote", 0))) @@ -1027,7 +965,6 @@ def to_format_status(self) -> List[str]: ) status.append(line + " " * (box_width - len(line) + 1) + "|") - # Fees row: base_fee + quote_fee = total base_fee = Decimal(str(custom.get("base_fee", 0))) quote_fee = Decimal(str(custom.get("quote_fee", 0))) fees_earned_quote = Decimal(str(custom.get("fees_earned_quote", 0))) @@ -1037,70 +974,49 @@ def to_format_status(self) -> List[str]: ) status.append(line + " " * (box_width - len(line) + 1) + "|") - # Price and rebalance thresholds lower_price = custom.get("lower_price") upper_price = custom.get("upper_price") if lower_price is not None and upper_price is not None and self._pool_price: - threshold = self.config.rebalance_threshold_pct / Decimal("100") - lower_threshold = Decimal(str(lower_price)) * (Decimal("1") - threshold) - upper_threshold = Decimal(str(upper_price)) * (Decimal("1") + threshold) - - # Lower threshold triggers SELL - check sell_price_min - if self.config.sell_price_min and lower_threshold < self.config.sell_price_min: - lower_str = "N/A" - else: - lower_str = f"{float(lower_threshold):.{price_decimals}f}" - - # Upper threshold triggers BUY - check buy_price_max - if self.config.buy_price_max and upper_threshold > self.config.buy_price_max: - upper_str = "N/A" - else: - upper_str = f"{float(upper_threshold):.{price_decimals}f}" + # Show limit prices (auto-close thresholds) + threshold_pct = self.config.rebalance_threshold_pct / Decimal("100") + lower_limit = Decimal(str(lower_price)) * (Decimal("1") - threshold_pct) + upper_limit = Decimal(str(upper_price)) * (Decimal("1") + threshold_pct) - line = f"| Price: {float(self._pool_price):.{price_decimals}f} | Rebalance if: <{lower_str} or >{upper_str}" + line = f"| Price: {float(self._pool_price):.{price_decimals}f} | Auto-close if: <{float(lower_limit):.{price_decimals}f} or >{float(upper_limit):.{price_decimals}f}" status.append(line + " " * (box_width - len(line) + 1) + "|") - # Status with icon state = custom.get("state", "UNKNOWN") state_icons = { - "IN_RANGE": "●", - "OUT_OF_RANGE": "○", - "OPENING": "◐", - "CLOSING": "◑", - "COMPLETE": "◌", - "NOT_ACTIVE": "○", + "IN_RANGE": "[in]", + "OUT_OF_RANGE": "[out]", + "OPENING": "[...]", + "CLOSING": "[x]", + "COMPLETE": "[done]", + "NOT_ACTIVE": "[-]", } - state_icon = state_icons.get(state, "?") + state_icon = state_icons.get(state, "[?]") status.append("|" + " " * box_width + "|") - line = f"| Position Status: [{state_icon} {state}]" + line = f"| Status: {state_icon} {state}" status.append(line + " " * (box_width - len(line) + 1) + "|") # Range visualization range_viz = self._create_price_range_visualization( Decimal(str(lower_price)), self._pool_price, - Decimal(str(upper_price)) + Decimal(str(upper_price)), + lower_limit, + upper_limit ) for viz_line in range_viz.split('\n'): line = f"| {viz_line}" status.append(line + " " * (box_width - len(line) + 1) + "|") - - # Rebalance timer if out of range - out_of_range_seconds = custom.get("out_of_range_seconds") - if out_of_range_seconds is not None: - beyond_threshold = self._is_beyond_rebalance_threshold(executor) - if beyond_threshold: - line = f"| Rebalance: {out_of_range_seconds}s / {self.config.rebalance_seconds}s" - else: - line = f"| Rebalance: waiting (below {float(self.config.rebalance_threshold_pct):.2f}% threshold)" - status.append(line + " " * (box_width - len(line) + 1) + "|") else: line = "| Position: None" status.append(line + " " * (box_width - len(line) + 1) + "|") - # === PRICE LIMITS VISUALIZATION === + # Price limits visualization has_limits = any([ self.config.sell_price_min, self.config.sell_price_max, self.config.buy_price_min, self.config.buy_price_max @@ -1125,75 +1041,18 @@ def to_format_status(self) -> List[str]: line = f"| {viz_line}" status.append(line + " " * (box_width - len(line) + 1) + "|") - # === BALANCES === - status.append("|" + " " * box_width + "|") - try: - wallet_base = self.market_data_provider.get_balance( - self.config.connector_name, self._base_token - ) - wallet_quote = self.market_data_provider.get_balance( - self.config.connector_name, self._quote_token - ) - - line = "| Balances:" - status.append(line + " " * (box_width - len(line) + 1) + "|") - - # Table header: Asset | Initial | Current (wallet) | Position | Change - header = f"| {'Asset':<8} {'Initial':>12} {'Current':>12} {'Position':>12} {'Change':>14}" - status.append(header + " " * (box_width - len(header) + 1) + "|") - - # Base token row - # Change = (wallet + position) - initial - if self._initial_base_balance is not None: - total_base = wallet_base + pos_base_amount - base_change = total_base - self._initial_base_balance - init_b = float(self._initial_base_balance) - wall_b = float(wallet_base) - pos_b = float(pos_base_amount) - chg_b = float(base_change) - line = f"| {self._base_token:<8} {init_b:>12.6f} {wall_b:>12.6f} {pos_b:>12.6f} {chg_b:>+14.6f}" - else: - wall_b = float(wallet_base) - pos_b = float(pos_base_amount) - line = f"| {self._base_token:<8} {'N/A':>12} {wall_b:>12.6f} {pos_b:>12.6f} {'N/A':>14}" - status.append(line + " " * (box_width - len(line) + 1) + "|") - - # Quote token row - if self._initial_quote_balance is not None: - total_quote = wallet_quote + pos_quote_amount - quote_change = total_quote - self._initial_quote_balance - init_q = float(self._initial_quote_balance) - wall_q = float(wallet_quote) - pos_q = float(pos_quote_amount) - chg_q = float(quote_change) - line = f"| {self._quote_token:<8} {init_q:>12.6f} {wall_q:>12.6f} {pos_q:>12.6f} {chg_q:>+14.6f}" - else: - wall_q = float(wallet_quote) - pos_q = float(pos_quote_amount) - line = f"| {self._quote_token:<8} {'N/A':>12} {wall_q:>12.6f} {pos_q:>12.6f} {'N/A':>14}" - status.append(line + " " * (box_width - len(line) + 1) + "|") - except Exception as e: - line = f"| Balances: Error fetching ({e})" - status.append(line + " " * (box_width - len(line) + 1) + "|") - - # === CLOSED POSITIONS SUMMARY === + # Closed positions summary status.append("|" + " " * box_width + "|") + closed_lp = [e for e in self.executors_info + if e.is_done and getattr(e.config, "type", None) == "lp_executor"] + closed_swaps = [e for e in self.executors_info + if e.is_done and getattr(e.config, "type", None) == "order_executor"] - closed = [e for e in self.executors_info if e.is_done] - - # Separate LP positions from swaps - closed_lp = [e for e in closed if getattr(e.config, "type", None) == "lp_executor"] - closed_swaps = [e for e in closed if getattr(e.config, "type", None) == "order_executor"] + buy_count = len([e for e in closed_lp if getattr(e.config, "side", None) == 1]) + sell_count = len([e for e in closed_lp if getattr(e.config, "side", None) == 2]) - # Count LP positions by side - range_count = len([e for e in closed_lp if getattr(e.config, "side", None) == TradeType.RANGE]) - buy_count = len([e for e in closed_lp if getattr(e.config, "side", None) == TradeType.BUY]) - sell_count = len([e for e in closed_lp if getattr(e.config, "side", None) == TradeType.SELL]) - - # Calculate fees from closed LP positions total_fees_base = Decimal("0") total_fees_quote = Decimal("0") - for e in closed_lp: total_fees_base += Decimal(str(e.custom_info.get("base_fee", 0))) total_fees_quote += Decimal(str(e.custom_info.get("quote_fee", 0))) @@ -1201,53 +1060,81 @@ def to_format_status(self) -> List[str]: pool_price = self._pool_price or Decimal("0") total_fees_value = total_fees_base * pool_price + total_fees_quote - line = f"| Closed Positions: {len(closed_lp)} (range:{range_count} buy:{buy_count} sell:{sell_count})" + line = f"| Closed Positions: {len(closed_lp)} (buy:{buy_count} sell:{sell_count})" status.append(line + " " * (box_width - len(line) + 1) + "|") - # Show swaps count if any if closed_swaps: - swap_buy = len([e for e in closed_swaps if e.custom_info.get("side") == "BUY"]) - swap_sell = len([e for e in closed_swaps if e.custom_info.get("side") == "SELL"]) - line = f"| Swaps Executed: {len(closed_swaps)} (buy:{swap_buy} sell:{swap_sell})" + line = f"| Swaps Executed: {len(closed_swaps)}" status.append(line + " " * (box_width - len(line) + 1) + "|") - fb = float(total_fees_base) - fq = float(total_fees_quote) - fv = float(total_fees_value) - line = f"| Fees Collected: {fb:.6f} {self._base_token} + {fq:.6f} {self._quote_token} = {fv:.6f} {self._quote_token}" + line = f"| Fees Collected: {float(total_fees_base):.6f} {self._base_token} + {float(total_fees_quote):.6f} {self._quote_token} = {float(total_fees_value):.6f} {self._quote_token}" status.append(line + " " * (box_width - len(line) + 1) + "|") status.append("+" + "-" * box_width + "+") return status def _create_price_range_visualization(self, lower_price: Decimal, current_price: Decimal, - upper_price: Decimal) -> str: - """Create visual representation of price range with current price marker""" - price_range = upper_price - lower_price - if price_range == 0: + upper_price: Decimal, lower_limit: Decimal, + upper_limit: Decimal) -> str: + """ + Create visual representation of price range with current price marker. + + Shows: R for rebalance limits, | for position limits, * for current price + Example: R----|---*--------------------------------|----R + """ + total_range = upper_limit - lower_limit + if total_range == 0: return f"[{float(lower_price):.6f}] (zero width)" - current_position = (current_price - lower_price) / price_range bar_width = 50 - current_pos = int(current_position * bar_width) - range_bar = ['─'] * bar_width - range_bar[0] = '├' - range_bar[-1] = '┤' + def price_to_pos(price: Decimal) -> int: + return int(((price - lower_limit) / total_range) * bar_width) + # Calculate positions + lower_pos = price_to_pos(lower_price) + upper_pos = price_to_pos(upper_price) + current_pos = price_to_pos(current_price) + + # Build bar (R at edges for rebalance limits) + range_bar = ['-'] * bar_width + range_bar[0] = 'R' + range_bar[-1] = 'R' + + # Place position limits (|) + if 0 < lower_pos < bar_width: + range_bar[lower_pos] = '|' + if 0 < upper_pos < bar_width: + range_bar[upper_pos] = '|' + + # Place current price marker (*) if current_pos < 0: - marker_line = '● ' + ''.join(range_bar) + marker_line = '* ' + ''.join(range_bar) elif current_pos >= bar_width: - marker_line = ''.join(range_bar) + ' ●' + marker_line = ''.join(range_bar) + ' *' else: - range_bar[current_pos] = '●' + range_bar[current_pos] = '*' marker_line = ''.join(range_bar) viz_lines = [] viz_lines.append(marker_line) + + # Price labels: show all four prices + lower_limit_str = f'{float(lower_limit):.6f}' lower_str = f'{float(lower_price):.6f}' upper_str = f'{float(upper_price):.6f}' - viz_lines.append(lower_str + ' ' * (bar_width - len(lower_str) - len(upper_str)) + upper_str) + upper_limit_str = f'{float(upper_limit):.6f}' + + # Build price label line with proper spacing + label_line = lower_limit_str + spacing1 = max(1, lower_pos - len(lower_limit_str)) + label_line += ' ' * spacing1 + lower_str + spacing2 = max(1, upper_pos - lower_pos - len(lower_str)) + label_line += ' ' * spacing2 + upper_str + spacing3 = max(1, bar_width - upper_pos - len(upper_str)) + label_line += ' ' * spacing3 + upper_limit_str + + viz_lines.append(label_line) return '\n'.join(viz_lines) @@ -1256,7 +1143,7 @@ def _create_price_limits_visualization( current_price: Decimal, pos_lower: Optional[Decimal] = None, pos_upper: Optional[Decimal] = None, - price_decimals: int = 8 + price_decimals: int = 6 ) -> Optional[str]: """Create visualization of sell/buy price limits on unified scale.""" viz_lines = [] From 17711dcf04b90d141aa227266e9ff5e474b61eca Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 9 Apr 2026 15:01:14 -0700 Subject: [PATCH 32/34] Revert formatting changes to orders_recorder.py Co-Authored-By: Claude Opus 4.5 --- services/orders_recorder.py | 204 +++++++++++++++++------------------- 1 file changed, 97 insertions(+), 107 deletions(-) diff --git a/services/orders_recorder.py b/services/orders_recorder.py index 46ecf983..04b41bce 100644 --- a/services/orders_recorder.py +++ b/services/orders_recorder.py @@ -21,25 +21,20 @@ class OrdersRecorder: Custom orders recorder that mimics Hummingbot's MarketsRecorder functionality but uses our AsyncDatabaseManager for storage. """ - + def __init__(self, db_manager: AsyncDatabaseManager, account_name: str, connector_name: str): self.db_manager = db_manager self.account_name = account_name self.connector_name = connector_name self._connector: Optional[ConnectorBase] = None - + # Create event forwarders similar to MarketsRecorder - self._create_order_forwarder = SourceInfoEventForwarder( - self._did_create_order) - self._fill_order_forwarder = SourceInfoEventForwarder( - self._did_fill_order) - self._cancel_order_forwarder = SourceInfoEventForwarder( - self._did_cancel_order) - self._fail_order_forwarder = SourceInfoEventForwarder( - self._did_fail_order) - self._complete_order_forwarder = SourceInfoEventForwarder( - self._did_complete_order) - + self._create_order_forwarder = SourceInfoEventForwarder(self._did_create_order) + self._fill_order_forwarder = SourceInfoEventForwarder(self._did_fill_order) + self._cancel_order_forwarder = SourceInfoEventForwarder(self._did_cancel_order) + self._fail_order_forwarder = SourceInfoEventForwarder(self._did_fail_order) + self._complete_order_forwarder = SourceInfoEventForwarder(self._did_complete_order) + # Event pairs mapping events to forwarders self._event_pairs = [ (MarketEvent.BuyOrderCreated, self._create_order_forwarder), @@ -50,13 +45,12 @@ def __init__(self, db_manager: AsyncDatabaseManager, account_name: str, connecto (MarketEvent.BuyOrderCompleted, self._complete_order_forwarder), (MarketEvent.SellOrderCompleted, self._complete_order_forwarder), ] - + def start(self, connector: ConnectorBase): """Start recording orders for the given connector""" # Idempotency guard: prevent double-registration of listeners if self._connector is not None: - logger.warning( - f"OrdersRecorder already started for {self.account_name}/{self.connector_name}, ignoring duplicate start") + logger.warning(f"OrdersRecorder already started for {self.account_name}/{self.connector_name}, ignoring duplicate start") return self._connector = connector @@ -64,20 +58,38 @@ def start(self, connector: ConnectorBase): # Subscribe to order events using the same pattern as MarketsRecorder for event, forwarder in self._event_pairs: connector.add_listener(event, forwarder) - - logger.info( - f"OrdersRecorder started for {self.account_name}/{self.connector_name}") - + logger.info(f"OrdersRecorder: Added listener for {event} with forwarder {forwarder}") + + # Debug: Check if listeners were actually added + if hasattr(connector, '_event_listeners'): + listeners = connector._event_listeners.get(event, []) + logger.info(f"OrdersRecorder: Event {event} now has {len(listeners)} listeners") + for i, listener in enumerate(listeners): + logger.info(f"OrdersRecorder: Listener {i}: {listener}") + + logger.info(f"OrdersRecorder started for {self.account_name}/{self.connector_name} with {len(self._event_pairs)} event listeners") + + # Debug: Print connector info + logger.info(f"OrdersRecorder: Connector type: {type(connector)}") + logger.info(f"OrdersRecorder: Connector name: {getattr(connector, 'name', 'unknown')}") + logger.info(f"OrdersRecorder: Connector ready: {getattr(connector, 'ready', 'unknown')}") + + # Test if forwarders are callable + for event, forwarder in self._event_pairs: + if callable(forwarder): + logger.info(f"OrdersRecorder: Forwarder for {event} is callable") + else: + logger.error(f"OrdersRecorder: Forwarder for {event} is NOT callable: {type(forwarder)}") + async def stop(self): """Stop recording orders""" if self._connector: # Remove all event listeners for event, forwarder in self._event_pairs: self._connector.remove_listener(event, forwarder) - - logger.info( - f"OrdersRecorder stopped for {self.account_name}/{self.connector_name}") - + + logger.info(f"OrdersRecorder stopped for {self.account_name}/{self.connector_name}") + def _extract_error_message(self, event) -> str: """Extract error message from various possible event attributes.""" # Try different possible attribute names for error messages @@ -86,76 +98,75 @@ def _extract_error_message(self, event) -> str: error_value = getattr(event, attr_name) if error_value: return str(error_value) - + # If no error message found, create a descriptive one return f"Order failed: {event.__class__.__name__}" - + def _did_create_order(self, event_tag: int, market: ConnectorBase, event: Union[BuyOrderCreatedEvent, SellOrderCreatedEvent]): """Handle order creation events - called by SourceInfoEventForwarder""" + logger.info(f"OrdersRecorder: _did_create_order called for order {getattr(event, 'order_id', 'unknown')}") try: - trade_type = TradeType.BUY if isinstance( - event, BuyOrderCreatedEvent) else TradeType.SELL + # Determine trade type from event + trade_type = TradeType.BUY if isinstance(event, BuyOrderCreatedEvent) else TradeType.SELL + logger.info(f"OrdersRecorder: Creating task to handle order created - {trade_type} order") asyncio.create_task(self._handle_order_created(event, trade_type)) except Exception as e: logger.error(f"Error in _did_create_order: {e}") - + def _did_fill_order(self, event_tag: int, market: ConnectorBase, event: OrderFilledEvent): """Handle order fill events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_filled(event)) except Exception as e: logger.error(f"Error in _did_fill_order: {e}") - + def _did_cancel_order(self, event_tag: int, market: ConnectorBase, event: Any): """Handle order cancel events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_cancelled(event)) except Exception as e: logger.error(f"Error in _did_cancel_order: {e}") - + def _did_fail_order(self, event_tag: int, market: ConnectorBase, event: Any): """Handle order failure events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_failed(event)) except Exception as e: logger.error(f"Error in _did_fail_order: {e}") - + def _did_complete_order(self, event_tag: int, market: ConnectorBase, event: Any): """Handle order completion events - called by SourceInfoEventForwarder""" try: asyncio.create_task(self._handle_order_completed(event)) except Exception as e: logger.error(f"Error in _did_complete_order: {e}") - + async def _handle_order_created(self, event: Union[BuyOrderCreatedEvent, SellOrderCreatedEvent], trade_type: TradeType): """Handle order creation events""" + logger.info(f"OrdersRecorder: _handle_order_created started for order {event.order_id}") try: async with self.db_manager.get_session_context() as session: order_repo = OrderRepository(session) - + # Check if order already exists first existing_order = await order_repo.get_order_by_client_id(event.order_id) if existing_order: - logger.info( - f"OrdersRecorder: Order {event.order_id} already exists with status {existing_order.status}") - + logger.info(f"OrdersRecorder: Order {event.order_id} already exists with status {existing_order.status}") + # Update exchange_order_id if we have it now and it was missing - exchange_order_id = getattr( - event, 'exchange_order_id', None) + exchange_order_id = getattr(event, 'exchange_order_id', None) if exchange_order_id and not existing_order.exchange_order_id: existing_order.exchange_order_id = exchange_order_id - logger.info( - f"OrdersRecorder: Updated exchange_order_id to {exchange_order_id} for order {event.order_id}") - + logger.info(f"OrdersRecorder: Updated exchange_order_id to {exchange_order_id} for order {event.order_id}") + # Update status if it's still in PENDING_CREATE or similar early state if existing_order.status in ["PENDING_CREATE", "PENDING", "SUBMITTED"]: existing_order.status = "OPEN" - logger.info( - f"OrdersRecorder: Updated status to OPEN for order {event.order_id}") - + logger.info(f"OrdersRecorder: Updated status to OPEN for order {event.order_id}") + await session.flush() return - + order_data = { "client_order_id": event.order_id, "account_name": self.account_name, @@ -169,11 +180,11 @@ async def _handle_order_created(self, event: Union[BuyOrderCreatedEvent, SellOrd "exchange_order_id": getattr(event, 'exchange_order_id', None) } await order_repo.create_order(order_data) - - logger.debug(f"Recorded order created: {event.order_id}") + + logger.info(f"OrdersRecorder: Successfully recorded order created: {event.order_id}") except Exception as e: - logger.error(f"Error recording order created: {e}") - + logger.error(f"OrdersRecorder: Error recording order created: {e}") + async def _handle_order_filled(self, event: OrderFilledEvent): """Handle order fill events""" try: @@ -184,7 +195,7 @@ async def _handle_order_filled(self, event: OrderFilledEvent): # Calculate fees trade_fee_paid = 0 trade_fee_currency = None - + if event.trade_fee: try: base_asset, quote_asset = event.trading_pair.split("-") @@ -224,9 +235,8 @@ async def _handle_order_filled(self, event: OrderFilledEvent): try: filled_amount = Decimal(str(event.amount)) average_fill_price = Decimal(str(event.price)) - fee_paid_decimal = Decimal( - str(trade_fee_paid)) if trade_fee_paid else None - + fee_paid_decimal = Decimal(str(trade_fee_paid)) if trade_fee_paid else None + order = await order_repo.update_order_fill( client_order_id=event.order_id, filled_amount=filled_amount, @@ -235,22 +245,18 @@ async def _handle_order_filled(self, event: OrderFilledEvent): fee_currency=trade_fee_currency ) except (ValueError, InvalidOperation) as e: - logger.error( - f"Error processing order fill for {event.order_id}: {e}, skipping update") + logger.error(f"Error processing order fill for {event.order_id}: {e}, skipping update") return - + # Create trade record using validated values if order: try: # Validate all values before creating trade record - validated_timestamp = event.timestamp if event.timestamp and not math.isnan( - event.timestamp) else time.time() - validated_fee = trade_fee_paid if trade_fee_paid and not math.isnan( - trade_fee_paid) else 0 + validated_timestamp = event.timestamp if event.timestamp and not math.isnan(event.timestamp) else time.time() + validated_fee = trade_fee_paid if trade_fee_paid and not math.isnan(trade_fee_paid) else 0 # Use exchange_trade_id if available (unique per fill), fallback to generated id - exchange_trade_id = getattr( - event, 'exchange_trade_id', None) + exchange_trade_id = getattr(event, 'exchange_trade_id', None) if exchange_trade_id: trade_id = f"{event.order_id}_{exchange_trade_id}" else: @@ -263,29 +269,22 @@ async def _handle_order_filled(self, event: OrderFilledEvent): "timestamp": datetime.fromtimestamp(validated_timestamp), "trading_pair": event.trading_pair, "trade_type": event.trade_type.name, - # Use validated amount - "amount": float(filled_amount), - # Use validated price - "price": float(average_fill_price), + "amount": float(filled_amount), # Use validated amount + "price": float(average_fill_price), # Use validated price "fee_paid": validated_fee, "fee_currency": trade_fee_currency } result = await trade_repo.create_trade(trade_data) if result is None: - logger.debug( - f"Trade {trade_id} already exists, skipping duplicate") + logger.debug(f"Trade {trade_id} already exists, skipping duplicate") except (ValueError, TypeError) as e: - logger.error( - f"Error creating trade record for {event.order_id}: {e}") - logger.error( - f"Trade data that failed: timestamp={event.timestamp}, " - f"amount={event.amount}, price={event.price}, fee={trade_fee_paid}") - - logger.debug(f"Recorded order fill: {event.order_id}") + logger.error(f"Error creating trade record for {event.order_id}: {e}") + logger.error(f"Trade data that failed: timestamp={event.timestamp}, amount={event.amount}, price={event.price}, fee={trade_fee_paid}") + + logger.debug(f"Recorded order fill: {event.order_id} - {event.amount} @ {event.price}") except Exception as e: - logger.error( - f"Error recording order fill for {event.order_id}: {e}") - + logger.error(f"Error recording order fill: {e}") + async def _handle_order_cancelled(self, event: Any): """Handle order cancellation events""" try: @@ -295,17 +294,16 @@ async def _handle_order_cancelled(self, event: Any): client_order_id=event.order_id, status="CANCELLED" ) - + logger.debug(f"Recorded order cancelled: {event.order_id}") except Exception as e: logger.error(f"Error recording order cancellation: {e}") - + def _get_order_details_from_connector(self, order_id: str) -> Optional[dict]: """Try to get order details from connector's tracked orders""" try: if self._connector and hasattr(self._connector, 'in_flight_orders'): - in_flight_order = self._connector.in_flight_orders.get( - order_id) + in_flight_order = self._connector.in_flight_orders.get(order_id) if in_flight_order: return { "trading_pair": in_flight_order.trading_pair, @@ -383,29 +381,26 @@ async def _handle_order_failed(self, event: Any): try: async with self.db_manager.get_session_context() as session: order_repo = OrderRepository(session) - + # Check if order exists, if not try to get details from connector's tracked orders existing_order = await order_repo.get_order_by_client_id(event.order_id) if existing_order: # Extract error message from various possible attributes error_msg = self._extract_error_message(event) - + # Update existing order with failure status and error message await order_repo.update_order_status( client_order_id=event.order_id, status="FAILED", error_message=error_msg ) - logger.info( - f"Updated existing order {event.order_id} to FAILED status") + logger.info(f"Updated existing order {event.order_id} to FAILED status") else: # Try to get order details from connector's tracked orders - order_details = self._get_order_details_from_connector( - event.order_id) + order_details = self._get_order_details_from_connector(event.order_id) if order_details: - logger.info( - f"Retrieved order details from connector for {event.order_id}: {order_details}") - + logger.info(f"Retrieved order details from connector for {event.order_id}: {order_details}") + # Create order record as FAILED with available details if order_details: order_data = { @@ -427,35 +422,32 @@ async def _handle_order_failed(self, event: Any): "account_name": self.account_name, "connector_name": self.connector_name, "trading_pair": "UNKNOWN", - "trade_type": "UNKNOWN", + "trade_type": "UNKNOWN", "order_type": "UNKNOWN", "amount": 0.0, "price": None, "status": "FAILED", "error_message": self._extract_error_message(event) } - + try: await order_repo.create_order(order_data) - logger.info( - f"Created failed order record for {event.order_id}") + logger.info(f"Created failed order record for {event.order_id}") except Exception as create_error: # If creation fails due to duplicate key, try to update existing order if "duplicate key" in str(create_error).lower() or "unique constraint" in str(create_error).lower(): - logger.info( - f"Order {event.order_id} already exists, updating status to FAILED") + logger.info(f"Order {event.order_id} already exists, updating status to FAILED") await order_repo.update_order_status( client_order_id=event.order_id, status="FAILED", - error_message=self._extract_error_message( - event) + error_message=self._extract_error_message(event) ) else: raise create_error - + except Exception as e: logger.error(f"Error recording order failure: {e}") - + async def _handle_order_completed(self, event: Any): """Handle order completion events""" try: @@ -464,10 +456,8 @@ async def _handle_order_completed(self, event: Any): order = await order_repo.get_order_by_client_id(event.order_id) if order: order.status = "FILLED" - order.exchange_order_id = getattr( - event, 'exchange_order_id', None) - await session.flush() - + order.exchange_order_id = getattr(event, 'exchange_order_id', None) + logger.debug(f"Recorded order completed: {event.order_id}") except Exception as e: - logger.error(f"Error recording order completion: {e}") + logger.error(f"Error recording order completion: {e}") \ No newline at end of file From ec2be3dd785b5c0cad0a4c1f7b7c0b5bbf65f1ab Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 9 Apr 2026 16:47:54 -0700 Subject: [PATCH 33/34] Fix closed position counts to use TradeType enum Sync from hummingbot: compare against TradeType.BUY/SELL/RANGE instead of integer values. Add range_count to display. Co-Authored-By: Claude Opus 4.5 --- bots/controllers/generic/lp_rebalancer/lp_rebalancer.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index b111abb5..ec0089a9 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -1048,8 +1048,9 @@ def to_format_status(self) -> List[str]: closed_swaps = [e for e in self.executors_info if e.is_done and getattr(e.config, "type", None) == "order_executor"] - buy_count = len([e for e in closed_lp if getattr(e.config, "side", None) == 1]) - sell_count = len([e for e in closed_lp if getattr(e.config, "side", None) == 2]) + buy_count = len([e for e in closed_lp if getattr(e.config, "side", None) == TradeType.BUY]) + sell_count = len([e for e in closed_lp if getattr(e.config, "side", None) == TradeType.SELL]) + range_count = len([e for e in closed_lp if getattr(e.config, "side", None) == TradeType.RANGE]) total_fees_base = Decimal("0") total_fees_quote = Decimal("0") @@ -1060,7 +1061,7 @@ def to_format_status(self) -> List[str]: pool_price = self._pool_price or Decimal("0") total_fees_value = total_fees_base * pool_price + total_fees_quote - line = f"| Closed Positions: {len(closed_lp)} (buy:{buy_count} sell:{sell_count})" + line = f"| Closed Positions: {len(closed_lp)} (buy:{buy_count} sell:{sell_count} range:{range_count})" status.append(line + " " * (box_width - len(line) + 1) + "|") if closed_swaps: From 4f738b15fd9a98eb72504e47a1ba3a91e921f1d5 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 16 Apr 2026 14:15:44 -0700 Subject: [PATCH 34/34] Simplify Gateway price fetching to use network swap provider Remove hardcoded gateway_default_pricing_connector mapping and use get_price with full network name which auto-resolves dex/trading_type from the network's configured swap provider. Co-Authored-By: Claude Opus 4.5 --- services/accounts_service.py | 34 +++++++--------------------------- 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/services/accounts_service.py b/services/accounts_service.py index 15125fee..70f5a1fd 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -438,16 +438,12 @@ class AccountsService: update the balances of each account. """ default_quotes = { - "hyperliquid": "USD", - "hyperliquid_perpetual": "USDC", + "hyperliquid": "USDC", + "hyperliquid_perpetual": "USD", "xrpl": "RLUSD", "kraken": "USD", } - gateway_default_pricing_connector = { - "ethereum": "uniswap/router", - "solana": "jupiter/router", - } - potential_wrapped_tokens = ["ETH", "SOL", "BNB", "POL", "AVAX", "FTM", "ONE", "GLMR", "MOVR"] + potential_wrapped_tokens = ["ETH", "SOL", "BNB", "POL", "AVAX"] # Cache for storing last successful prices by trading pair _last_known_prices = {} @@ -2164,10 +2160,6 @@ async def _fetch_gateway_prices_immediate(self, chain: str, network: str, Fetch prices immediately from Gateway for the given tokens. This is used to get prices right away instead of waiting for the background update task. - Uses the same pricing connector resolution as MarketDataProvider.update_rates_task(): - - solana -> jupiter/router - - ethereum -> uniswap/router - Args: chain: Blockchain chain (e.g., 'solana', 'ethereum') network: Network name (e.g., 'mainnet-beta', 'mainnet') @@ -2184,18 +2176,8 @@ async def _fetch_gateway_prices_immediate(self, chain: str, network: str, rate_oracle = RateOracle.get_instance() prices = {} - # Resolve pricing connector based on chain (same logic as MarketDataProvider) - pricing_connector = self.gateway_default_pricing_connector.get(chain) - if not pricing_connector: - logger.warning(f"No pricing connector configured for chain '{chain}', skipping immediate price fetch") - return prices - - # Parse pricing connector into dex and trading_type (e.g., "jupiter/router" -> "jupiter", "router") - if "/" in pricing_connector: - dex_name, trading_type = pricing_connector.split("/", 1) - else: - dex_name = pricing_connector - trading_type = "router" + # Construct full network name (e.g., "solana-mainnet-beta") + full_network = f"{chain}-{network}" # Create tasks for all tokens in parallel tasks = [] @@ -2229,11 +2211,9 @@ async def _fetch_gateway_prices_immediate(self, chain: str, network: str, continue try: + # get_price will auto-fetch dex/trading_type from network's swap provider task = gateway_client.get_price( - chain=chain, - network=network, - dex=dex_name, - trading_type=trading_type, + network=full_network, base_asset=token, quote_asset=quote_asset, amount=Decimal("1"),