From 584e4d064c93117750b82492c479c2e000c17ea8 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 4 Jun 2026 13:39:31 -0700 Subject: [PATCH 1/2] feat(accounts): fast add-credential with immediate balances + background bring-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a connector credential previously blocked on the full trading-connector bring-up before returning. For Hyperliquid perp that is ~85s (a metaAndAssetCtxs call per HIP-3 DEX in both symbol-map init and trading-rules update, plus order-book / websocket startup waits), so the UI sat on "Saving credentials…" for a long time and the new balances only appeared a refresh loop later. Split the bring-up into a fast part and a heavy part: - update_connector_keys now creates the connector, fetches balances only (mirroring the Hummingbot CLI `connect`), CACHES it, and schedules the heavy bring-up (symbol map, trading rules, HIP-3 markets, positions, recorders, network) in the background, in place on the cached connector. Bad keys still fail fast here and the caller deletes the credential. - _create_and_initialize_trading_connector keeps the same end state by calling the same new _finish_trading_connector_init after the fast balances fetch. - add_credentials surfaces the new balances immediately via a scoped update_account_state(account_names=[...], connector_names=[...], skip_gateway=True) instead of a full all-connectors refresh, wrapped so a display-only refresh failure never deletes a just-validated credential. Delete-race guarded: the background task checks list_available_credentials before and after finishing and tears the connector back down if the credential was removed meanwhile, so a removed key can't linger in the cache / portfolio. Background tasks are held in a module-level set so they aren't GC'd mid-run. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/accounts_service.py | 23 ++++++++-- services/unified_connector_service.py | 64 ++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/services/accounts_service.py b/services/accounts_service.py index 9d48830e..e6d0c39e 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -950,10 +950,25 @@ async def add_credentials(self, account_name: str, connector_name: str, credenti raise HTTPException(status_code=500, detail="Connector service not initialized") try: - # Update the connector keys (this saves the credentials to file and validates them) - connector = await self._connector_service.update_connector_keys(account_name, connector_name, credentials) - - await self.update_account_state() + # Saves the credentials to file + fast balances-only validation, and caches the connector + # so its balances are available immediately; the full bring-up runs in the background. + await self._connector_service.update_connector_keys(account_name, connector_name, credentials) + + # Surface the new connector's balances in the portfolio right away — scoped to just this + # connector and skipping Gateway, so it's fast (no all-connectors / unreachable-Gateway + # refresh). The full bring-up finishes in the background. A failure here is display-only + # and must NOT delete the just-validated credential. + try: + await self.update_account_state( + account_names=[account_name], + connector_names=[connector_name], + skip_gateway=True, + ) + except Exception as refresh_err: + logger.warning( + f"Initial account-state refresh for {connector_name} failed (will refresh on the " + f"next loop): {refresh_err}" + ) except Exception as e: logger.error(f"Error adding connector credentials for account {account_name}: {e}") await self.delete_credentials(account_name, connector_name) diff --git a/services/unified_connector_service.py b/services/unified_connector_service.py index 830a29ed..cb733969 100644 --- a/services/unified_connector_service.py +++ b/services/unified_connector_service.py @@ -35,6 +35,9 @@ logger = logging.getLogger(__name__) +# Strong refs to fire-and-forget background connector-init tasks so they aren't GC'd mid-run. +_BACKGROUND_INIT_TASKS: set = set() + class UnifiedConnectorService: """ @@ -581,10 +584,27 @@ async def _create_and_initialize_trading_connector( # Authenticate and create connector connector = self._create_trading_connector(account_name, connector_name) + # Fetch balances first (fast, account-level) so the connector is usable for the portfolio + # before the heavy bring-up, then finish the rest. + await connector._update_balances() + await self._finish_trading_connector_init(connector, account_name, connector_name) + + logger.info(f"Initialized trading connector {connector_name} for {account_name}") + return connector + + async def _finish_trading_connector_init( + self, + connector: ConnectorBase, + account_name: str, + connector_name: str, + ) -> None: + """Heavy part of trading-connector bring-up: symbol map, trading rules, positions, recorders, + metrics, and network tasks. Split out so it can run in the background after a fast + balances-only init, upgrading an already-cached connector in place without blocking + add-credential or flickering the portfolio.""" # Initialize symbol map and trading rules await connector._initialize_trading_pair_symbol_map() await connector._update_trading_rules() - await connector._update_balances() # Perpetual-specific setup if self._is_perpetual_connector(connector): @@ -625,9 +645,6 @@ async def _create_and_initialize_trading_connector( except Exception as e: logger.error(f"Error updating initial order status for {connector_name}: {e}") - logger.info(f"Initialized trading connector {connector_name} for {account_name}") - return connector - def _create_trading_connector( self, account_name: str, @@ -1061,8 +1078,43 @@ async def update_connector_keys( # Properly stop old connector (stops recorders, network tasks, cleans up caches) await self.stop_trading_connector(account_name, connector_name) - # Create new connector with fresh recorders - return await self.get_trading_connector(account_name, connector_name) + # Fast path: create the connector and fetch balances only (mirrors the Hummingbot CLI + # `connect`), then CACHE it so the new connector's balances surface in the portfolio + # immediately via a scoped update_account_state — instead of blocking ~85s on the full + # bring-up (which for Hyperliquid perp fans out a metaAndAssetCtxs call per HIP-3 DEX plus + # order-book/websocket startup waits). Bad keys still fail here and the caller deletes the + # credential. + connector = self._create_trading_connector(account_name, connector_name) + await connector._update_balances() + self._trading_connectors.setdefault(account_name, {})[connector_name] = connector + + # Finish the heavy bring-up (symbol map, trading rules, HIP-3 markets, network, recorders) in + # the background, IN PLACE on the cached connector, so it becomes trade-ready without blocking + # the response or flickering the portfolio. + task = asyncio.create_task(self._finish_init_background(account_name, connector_name, connector)) + _BACKGROUND_INIT_TASKS.add(task) + task.add_done_callback(_BACKGROUND_INIT_TASKS.discard) + + return connector + + async def _finish_init_background(self, account_name: str, connector_name: str, connector: ConnectorBase) -> None: + """Finish the heavy trading-connector bring-up in the background, upgrading the already-cached + balances-only connector in place so it becomes trade-ready — without blocking add-credential.""" + try: + # Skip if the credential was already removed before this ran. + if connector_name not in self.list_available_credentials(account_name): + self.clear_trading_connector(account_name, connector_name) + return + await self._finish_trading_connector_init(connector, account_name, connector_name) + # If the credential was deleted WHILE finishing, tear the connector back down so a removed + # key doesn't linger in the cache / account state / portfolio. + if connector_name not in self.list_available_credentials(account_name): + await self.stop_trading_connector(account_name, connector_name) + self.clear_trading_connector(account_name, connector_name) + return + logger.info(f"Background-finished trading connector {connector_name} for {account_name}") + except Exception as e: + logger.error(f"Background finish failed for {account_name}/{connector_name}: {e}") def clear_trading_connector( self, From bd454b26f39ad8df1d65736b45dea107e68294d5 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 4 Jun 2026 16:34:46 -0700 Subject: [PATCH 2/2] fix(market-data): initialize exchange data before trading-pair validation probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate_trading_pair() probes a pair by calling feed.fetch_candles() directly, but fetch_candles() does not run initialize_exchange_data(). Feeds that resolve exchange-specific symbol data there (e.g. Hyperliquid spot builds _coins_dict in _initialize_coins_dict) then dereference uninitialized state in their REST payload — `self._coins_dict[self._trading_pair]` raises 'NoneType' object is not subscriptable, which the endpoint wraps as "Trading pair 'PURR-USDC' appears to be invalid on 'hyperliquid'". Both /market-data/candles and /market-data/historical-candles gate on this validation, so spot pairs failed on both the primary call and the fallback (perp was unaffected — its payload uses the bare base asset). The real fetch path get_historical_candles already initializes exchange data first; mirror that in the validation probe. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/market_data_service.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/services/market_data_service.py b/services/market_data_service.py index ab002857..0ebe552d 100644 --- a/services/market_data_service.py +++ b/services/market_data_service.py @@ -396,6 +396,11 @@ async def validate_trading_pair(connector_name: str, trading_pair: str, interval max_records=10, )) try: + # Some feeds (e.g. Hyperliquid spot) resolve exchange-specific symbol data in + # initialize_exchange_data() that fetch_candles' REST payload depends on; without it + # the payload dereferences uninitialized state. get_historical_candles initializes the + # same way, so mirror it here in the validation fetch. + await feed.initialize_exchange_data() end_time = int(_time.time()) candles = await feed.fetch_candles(end_time=end_time, limit=1) if candles is None or len(candles) == 0: