From 7a79b919d3ebf9aa2a1869e9463fbcef3d4b7839 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 28 Jul 2026 15:09:19 +0300 Subject: [PATCH 1/9] (feat) market data models improvement --- models/market_data.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/models/market_data.py b/models/market_data.py index 5f12148e..34277bd8 100644 --- a/models/market_data.py +++ b/models/market_data.py @@ -198,7 +198,12 @@ class TradingPairResponse(BaseModel): class TickerInfo(BaseModel): """A single collected ticker.""" price: float = Field(description="Mid price (or last price when bid/ask unavailable)") - volume: Optional[float] = Field(default=None, description="24h quote volume when available") + base_volume: Optional[float] = Field( + default=None, description="24h volume denominated in the base asset" + ) + quote_volume: Optional[float] = Field( + default=None, description="24h volume denominated in the quote asset" + ) timestamp: float = Field(description="Collection timestamp") @@ -207,6 +212,9 @@ class ConnectorTickersResponse(BaseModel): connector: str = Field(description="Connector name") count: int = Field(description="Number of tickers") tickers: Dict[str, TickerInfo] = Field(description="Trading pair to ticker mapping") + updated_at: Optional[float] = Field( + default=None, description="Unix timestamp of the last successful fetch" + ) class AllTickersResponse(BaseModel): From 48642aa0a74bc7f02833c786d54080f27d273cd8 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 28 Jul 2026 15:09:34 +0300 Subject: [PATCH 2/9] (feat) add refresh to routes --- routers/market_data.py | 44 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/routers/market_data.py b/routers/market_data.py index 194fc399..b812938e 100644 --- a/routers/market_data.py +++ b/routers/market_data.py @@ -1,8 +1,9 @@ import asyncio import logging import time +from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from hummingbot.data_feed.candles_feed.candles_factory import CandlesFactory, UnsupportedConnectorException from hummingbot.data_feed.candles_feed.data_types import CandlesConfig, HistoricalCandlesConfig @@ -35,6 +36,8 @@ ) from models.market_data import CandlesConfigRequest from services.market_data_service import MarketDataService +from services.ticker_sources import TickerFetchError, TickerUnsupportedError +from services.unified_connector_service import UnknownConnectorError logger = logging.getLogger(__name__) @@ -297,12 +300,45 @@ async def get_all_tickers( @router.get("/tickers/{connector_name}", response_model=ConnectorTickersResponse) async def get_connector_tickers( connector_name: str, + refresh: bool = Query(False, description="Force a fresh fetch, ignoring the cache"), + max_age: Optional[float] = Query( + None, ge=0, description="Accept cached tickers up to this age in seconds" + ), market_data_manager: MarketDataService = Depends(get_market_data_service) ): - """Get the latest collected tickers for a single connector.""" - tickers = market_data_manager.get_tickers(connector_name).get(connector_name, {}) + """ + Get the tickers for a single connector, with 24h base and quote volume where available. + + Tickers are fetched on demand through a keyless public data connector when the cache is + missing or stale, so this works for exchanges no API keys are configured for. The connector + then joins the background refresh cycle. + """ + try: + tickers = await market_data_manager.fetch_connector_tickers( + connector_name, max_age=max_age, force=refresh + ) + except UnknownConnectorError: + raise HTTPException(status_code=404, detail=f"Connector '{connector_name}' not found") + except TickerUnsupportedError as e: + # Known connector, but no amount of retrying will make it serve public tickers. + raise HTTPException(status_code=400, detail=str(e)) + except TickerFetchError as e: + raise HTTPException( + status_code=502, detail=f"Failed to fetch tickers from '{connector_name}': {e}" + ) + except Exception as e: + logger.error( + f"Unexpected error fetching tickers for {connector_name}: {e}", exc_info=True + ) + raise HTTPException(status_code=500, detail=str(e)) + info = _tickers_to_info(tickers) - return ConnectorTickersResponse(connector=connector_name, count=len(info), tickers=info) + return ConnectorTickersResponse( + connector=connector_name, + count=len(info), + tickers=info, + updated_at=market_data_manager.ticker_updated_at(connector_name), + ) @router.post("/rates", response_model=RatesResponse) From 59cf7ab7f6d2986c7f12b02d062778d5e23adc93 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 28 Jul 2026 15:10:14 +0300 Subject: [PATCH 3/9] (feat) improve ticker sources --- services/ticker_sources.py | 684 +++++++++++++++++++++++++++++-------- 1 file changed, 540 insertions(+), 144 deletions(-) diff --git a/services/ticker_sources.py b/services/ticker_sources.py index 9f34f151..b1194575 100644 --- a/services/ticker_sources.py +++ b/services/ticker_sources.py @@ -3,56 +3,110 @@ Each adapter fetches a single bulk "all tickers" payload from one exchange and normalizes it into ``{hb_trading_pair -> Ticker}``. The request goes through the connector's own -``_api_get`` so the exchange base URL, domain, authentication wrapper and rate-limit -throttling are reused (unlike raw HTTP calls). The raw exchange symbol is mapped to the -Hummingbot ``BASE-QUOTE`` format via ``trading_pair_associated_to_exchange_symbol``. - -Adapters are keyed by connector name. Connectors without an adapter are skipped by the -collector (their held assets are still priced by the per-pair fallback in AccountsService). -Adding support for a new exchange is just registering one more entry in ``TICKER_ADAPTERS``. - -The price/volume field names mirror the parsing already maintained upstream in -``hummingbot.core.rate_oracle.sources.*`` (price) and PR #173 (24h quote volume). +``_api_get``/``_api_post`` so the exchange base URL, domain, authentication wrapper and +rate-limit throttling are reused (unlike raw HTTP calls). The raw exchange symbol is mapped +to the Hummingbot ``BASE-QUOTE`` format via the connector's trading pair symbol map, which +initializes itself lazily from a public endpoint -- so every adapter here works on a keyless, +never-started data connector. + +Most exchanges are described declaratively in ``TICKER_SPECS`` (one table row per exchange: +request path, row shaper and field names). Exchanges whose payload needs real logic to join +against the symbol map get a hand-written adapter in ``TICKER_ADAPTERS``. Connectors in +neither table fall back to the price-only ``_generic`` adapter. + +Volume is reported in both units. ``base_volume`` and ``quote_volume`` are stored exactly as +the exchange reports them, and whichever side the exchange omits is derived from the mid +price. Getting this right matters twice: callers see comparable numbers, and +``MarketDataService._rebuild_price_pool`` picks the most liquid market for a pair by comparing +quote volume across exchanges. """ import asyncio import logging import time -from decimal import Decimal, InvalidOperation -from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple +from dataclasses import dataclass, field +from decimal import Decimal, DivisionByZero, InvalidOperation +from typing import ( + Any, + Awaitable, + Callable, + Dict, + Iterable, + List, + Mapping, + Optional, + Tuple, + Union, +) logger = logging.getLogger(__name__) # Per-connector fetch timeout (seconds). One slow exchange must not stall the whole cycle. -_FETCH_TIMEOUT = 20.0 +# This budget covers the symbol map load plus the ticker request. +_FETCH_TIMEOUT = 30.0 + +# Symbol map load timeout, kept inside _FETCH_TIMEOUT. Some exchange-info endpoints are slow: +# gate_io_perpetual alone takes ~6s, and more when many connectors refresh concurrently. +_SYMBOL_MAP_TIMEOUT = 15.0 + +# The base get_last_traded_prices implementation issues ONE HTTP REQUEST PER PAIR. Above this +# many pairs the generic fallback refuses rather than firing hundreds of requests. +_MAX_LAST_TRADED_PAIRS = 30 + + +class TickerFetchError(RuntimeError): + """Ticker retrieval failed for a connector (network, parse, or empty symbol map).""" + + +class TickerUnsupportedError(TickerFetchError): + """This connector cannot serve public tickers through a keyless data connector.""" class Ticker: - """A single normalized ticker: mid price, optional 24h quote volume, and a timestamp.""" + """A single normalized ticker: mid price plus 24h base and quote volume.""" - __slots__ = ("price", "volume", "timestamp") + __slots__ = ("price", "base_volume", "quote_volume", "timestamp") - def __init__(self, price: Decimal, volume: Optional[Decimal], timestamp: float): + def __init__( + self, + price: Decimal, + base_volume: Optional[Decimal] = None, + quote_volume: Optional[Decimal] = None, + timestamp: float = 0.0, + ): self.price = price - self.volume = volume + self.base_volume = base_volume + self.quote_volume = quote_volume self.timestamp = timestamp + # Whichever side the exchange omits is derived from the mid price, so that exchanges + # reporting only one unit still expose both. Exchange-reported values are never + # overwritten, so a derived number only ever fills a gap. + if price is not None and price > 0: + try: + if self.quote_volume is None and self.base_volume is not None: + self.quote_volume = self.base_volume * price + elif self.base_volume is None and self.quote_volume is not None: + self.base_volume = self.quote_volume / price + except (InvalidOperation, DivisionByZero, ArithmeticError): + pass + def to_dict(self) -> Dict[str, Any]: return { "price": float(self.price), - "volume": float(self.volume) if self.volume is not None else None, + "base_volume": float(self.base_volume) if self.base_volume is not None else None, + "quote_volume": float(self.quote_volume) if self.quote_volume is not None else None, "timestamp": self.timestamp, } def _to_decimal(value: Any) -> Optional[Decimal]: - """Parse a value to a positive Decimal, returning None on empty/invalid/non-positive input.""" + """Parse a value to a Decimal, returning None on empty or invalid input.""" if value is None or value == "": return None try: - dec = Decimal(str(value)) + return Decimal(str(value)) except (InvalidOperation, ValueError, TypeError): return None - return dec def _mid(bid: Any, ask: Any, last: Any) -> Optional[Decimal]: @@ -67,96 +121,400 @@ def _mid(bid: Any, ask: Any, last: Any) -> Optional[Decimal]: return None -async def _normalize( - connector, - rows: List[Dict[str, Any]], - extract: Callable[[Dict[str, Any]], Tuple[Any, Optional[Decimal], Optional[Decimal]]], +# ==================== Declarative spec plumbing ==================== + +# A field location inside a ticker row: a plain key, or a path mixing keys and list indices +# (e.g. ``("bid", 0)`` for AscendEx's ``[price, size]`` pairs, ``("v", 1)`` for Kraken's +# ``[today, last_24h]`` volume pairs). +FieldPath = Union[str, Tuple[Any, ...], None] + + +def _get(row: Dict[str, Any], path: FieldPath) -> Any: + """Read a value from a row by key or nested path. Returns None if any step is missing.""" + if path is None: + return None + if isinstance(path, str): + return row.get(path) + node: Any = row + for step in path: + if isinstance(step, int): + if not isinstance(node, (list, tuple)) or len(node) <= step: + return None + node = node[step] + else: + if not isinstance(node, dict): + return None + node = node.get(step) + if node is None: + return None + return node + + +def _rows_list(payload: Any) -> List[Dict[str, Any]]: + """The payload is already a flat list of rows.""" + return [r for r in payload if isinstance(r, dict)] if isinstance(payload, list) else [] + + +def _rows_at(*keys: str) -> Callable[[Any], List[Dict[str, Any]]]: + """The rows live at a nested key path, e.g. ``data`` or ``result.list``.""" + + def shaper(payload: Any) -> List[Dict[str, Any]]: + node = payload + for key in keys: + if not isinstance(node, dict): + return [] + node = node.get(key) + return [r for r in node if isinstance(r, dict)] if isinstance(node, list) else [] + + return shaper + + +def _rows_keyed_dict(*keys: str, key_field: str = "_key") -> Callable[[Any], List[Dict[str, Any]]]: + """The rows are a symbol-keyed dict-of-dicts; the key is injected as ``key_field``.""" + + def shaper(payload: Any) -> List[Dict[str, Any]]: + node = payload + for key in keys: + if not isinstance(node, dict): + return [] + node = node.get(key) + if not isinstance(node, dict): + return [] + return [{**value, key_field: key} for key, value in node.items() if isinstance(value, dict)] + + return shaper + + +@dataclass(frozen=True) +class TickerSpec: + """Declarative description of one exchange's bulk ticker endpoint. + + ``limit_id`` is deliberately absent. ExchangePyBase._api_request falls back to + ``throttler_limit_id = limit_id or path_url`` and every path below is registered as a + RateLimit id under exactly that name. The connectors that override _api_request + (okx_perpetual, bybit_perpetual) compute their own limit id only when limit_id is None, + so passing one would bypass their throttling. + """ + + # --- request --- + path: Union[str, Dict[str, str], Callable[[], Any]] + method: str = "GET" # "GET" or "POST" + params: Optional[Dict[str, Any]] = None + data: Optional[Dict[str, Any]] = None # POST body + headers: Optional[Dict[str, str]] = None + # --- response shape --- + rows: Callable[[Any], List[Dict[str, Any]]] = field(default=_rows_list) + # --- field mapping --- + symbol: FieldPath = "symbol" + bid: FieldPath = None + ask: FieldPath = None + last: FieldPath = None + base_volume: FieldPath = None # 24h volume denominated in the BASE asset + quote_volume: FieldPath = None # 24h volume denominated in the QUOTE asset + + +def _bybit_perpetual_path() -> Dict[str, str]: + """Bybit perpetual's _api_request indexes the endpoint by market, so it needs the dict.""" + from hummingbot.connector.derivative.bybit_perpetual import bybit_perpetual_constants + + return bybit_perpetual_constants.LATEST_SYMBOL_INFORMATION_ENDPOINT + + +# Bulk ticker endpoints, with every price and volume field verified against the live payload. +# Adding an exchange is normally just one more row here. +TICKER_SPECS: Dict[str, TickerSpec] = { + "binance": TickerSpec( + path="/ticker/24hr", + symbol="symbol", bid="bidPrice", ask="askPrice", last="lastPrice", + base_volume="volume", quote_volume="quoteVolume", + ), + # The futures 24hr ticker carries no bid/ask, so the price falls back to lastPrice. + "binance_perpetual": TickerSpec( + path="v1/ticker/24hr", # the base URL already carries /fapi/ + symbol="symbol", last="lastPrice", + base_volume="volume", quote_volume="quoteVolume", + ), + "gate_io": TickerSpec( + path="spot/tickers", + symbol="currency_pair", bid="highest_bid", ask="lowest_ask", last="last", + base_volume="base_volume", quote_volume="quote_volume", + ), + "gate_io_perpetual": TickerSpec( + path="futures/usdt/tickers", + symbol="contract", bid="highest_bid", ask="lowest_ask", last="last", + base_volume="volume_24h_base", quote_volume="volume_24h_quote", + ), + "okx": TickerSpec( + path="/api/v5/market/tickers", params={"instType": "SPOT"}, rows=_rows_at("data"), + symbol="instId", bid="bidPx", ask="askPx", last="last", + base_volume="vol24h", quote_volume="volCcy24h", + ), + # Unit trap: for instType=SWAP, volCcy24h is BASE-currency volume (unlike SPOT, where it is + # quote volume) and vol24h counts contracts. Only the base side is trustworthy here, so the + # quote volume is derived from it. + "okx_perpetual": TickerSpec( + path="/api/v5/market/tickers", params={"instType": "SWAP"}, rows=_rows_at("data"), + symbol="instId", bid="bidPx", ask="askPx", last="last", + base_volume="volCcy24h", + ), + "kucoin": TickerSpec( + path="/api/v1/market/allTickers", rows=_rows_at("data", "ticker"), + symbol="symbol", bid="buy", ask="sell", last="last", + base_volume="vol", quote_volume="volValue", + ), + "bybit": TickerSpec( + path="/v5/market/tickers", params={"category": "spot"}, rows=_rows_at("result", "list"), + symbol="symbol", bid="bid1Price", ask="ask1Price", last="lastPrice", + base_volume="volume24h", quote_volume="turnover24h", + ), + "bybit_perpetual": TickerSpec( + path=_bybit_perpetual_path, params={"category": "linear"}, + rows=_rows_at("result", "list"), + symbol="symbol", bid="bid1Price", ask="ask1Price", last="lastPrice", + base_volume="volume24h", quote_volume="turnover24h", + ), + "mexc": TickerSpec( + path="/ticker/24hr", headers={"Content-Type": "application/json"}, + symbol="symbol", bid="bidPrice", ask="askPrice", last="lastPrice", + base_volume="volume", quote_volume="quoteVolume", + ), + "htx": TickerSpec( + path="/market/tickers", rows=_rows_at("data"), + symbol="symbol", bid="bid", ask="ask", last="close", + base_volume="amount", quote_volume="vol", + ), + # Unit fix: AscendEx's `volume` is BASE volume. It used to be stored as quote volume. + "ascend_ex": TickerSpec( + path="spot/ticker", rows=_rows_at("data"), + symbol="symbol", bid=("bid", 0), ask=("ask", 0), + base_volume="volume", + ), + # Kraken returns a symbol-keyed dict of dicts, already unwrapped from `result` by the + # connector's REST post-processor. `v` and `p` are [today, last_24h] pairs, so the 24h + # base volume is v[1]. Kraken reports no quote volume, so it is derived. + "kraken": TickerSpec( + path="/0/public/Ticker", rows=_rows_keyed_dict(), + symbol="_key", bid=("b", 0), ask=("a", 0), last=("c", 0), + base_volume=("v", 1), + ), +} + +# Connector name variants that share another connector's spec/adapter. +_SPEC_ALIASES: Dict[str, str] = { + "kucoin_hft": "kucoin", +} + +# Known connectors that cannot serve public tickers without credentials or a running data +# source. The reason is surfaced verbatim to the caller, since retrying will never help. +UNSUPPORTED_TICKER_CONNECTORS: Dict[str, str] = { + "coinbase_advanced_trade": "requires API credentials even for public product data", + "decibel_perpetual": "requires API credentials on every endpoint", + "architect_perpetual": "requires API credentials for its public endpoints", + "injective_v2": "needs a running Injective data source rather than a REST ticker endpoint", + "injective_v2_perpetual": "needs a running Injective data source rather than a REST ticker endpoint", + "xrpl": "needs a running XRPL node pool rather than a REST ticker endpoint", + "dydx_v4_perpetual": "needs a running indexer data source rather than a REST ticker endpoint", +} + + +def _canonical(connector_name: str) -> str: + """Resolve a connector name through the alias table.""" + return _SPEC_ALIASES.get(connector_name, connector_name) + + +# ==================== Normalization ==================== + +def _normalize( + symbol_map: Mapping[str, str], + rows: Iterable[Dict[str, Any]], + extract: Callable[ + [Dict[str, Any]], + Tuple[Any, Optional[Decimal], Optional[Decimal], Optional[Decimal]], + ], + connector_name: str, ) -> Dict[str, Ticker]: """ Map raw rows to ``{hb_pair -> Ticker}``. - ``extract`` returns ``(raw_symbol, price, volume)`` for a row. Rows for symbols the - connector does not track (KeyError from the symbol map) or with no usable price are - skipped. Each row is guarded so one malformed entry cannot abort the whole exchange. + ``extract`` returns ``(raw_symbol, price, base_volume, quote_volume)`` for a row. Rows whose + symbol the connector does not track, or with no usable price, are skipped; each row is + guarded so one malformed entry cannot abort the whole exchange. + + Raises TickerFetchError when rows were present but none matched the symbol map, which is + what a payload/symbol-map key mismatch looks like. Silently returning {} there would show + up as an empty 200 with no clue as to why. """ now = time.time() out: Dict[str, Ticker] = {} + seen = 0 + unmapped = 0 + sample_unmapped: List[str] = [] + for row in rows: + seen += 1 try: - raw_symbol, price, volume = extract(row) + raw_symbol, price, base_volume, quote_volume = extract(row) if price is None or price <= 0: continue - pair = await connector.trading_pair_associated_to_exchange_symbol(raw_symbol) - except KeyError: - continue # symbol not tracked by this connector + pair = symbol_map.get(raw_symbol) if isinstance(raw_symbol, str) else None + if pair is None: + unmapped += 1 + if len(sample_unmapped) < 3: + sample_unmapped.append(str(raw_symbol)) + continue + if base_volume is not None and base_volume < 0: + base_volume = None + if quote_volume is not None and quote_volume < 0: + quote_volume = None except Exception as e: # noqa: BLE001 - never let one bad row kill the batch - logger.debug(f"Skipping ticker row {row!r}: {e}") + logger.debug(f"[{connector_name}] skipping ticker row {row!r}: {e}") continue - out[pair] = Ticker(price=price, volume=volume, timestamp=now) + out[pair] = Ticker( + price=price, base_volume=base_volume, quote_volume=quote_volume, timestamp=now + ) + + if seen and not out: + raise TickerFetchError( + f"'{connector_name}': none of {seen} ticker rows matched the symbol map " + f"({len(symbol_map)} entries). Sample payload symbols={sample_unmapped}, " + f"sample map keys={list(symbol_map)[:3]}" + ) + if unmapped: + logger.info( + f"[{connector_name}] tickers: mapped {len(out)}/{seen} rows " + f"({unmapped} symbols not in the symbol map, e.g. {sample_unmapped})" + ) return out -# ==================== Per-exchange adapters ==================== - -async def _binance(connector) -> Dict[str, Ticker]: - # /ticker/24hr carries both bid/ask and quoteVolume; works for spot and perpetual - # (the connector applies its own fapi/spot base URL). bookTicker (get_all_pairs_prices) - # has no volume, so the 24hr endpoint is used here. - path = "/ticker/24hr" - rows = await connector._api_get(path_url=path, limit_id=path, is_auth_required=False) - return await _normalize( - connector, rows, - lambda r: (r["symbol"], _mid(r.get("bidPrice"), r.get("askPrice"), r.get("lastPrice")), - _to_decimal(r.get("quoteVolume"))), - ) - - -async def _gate_io(connector) -> Dict[str, Ticker]: - path = "spot/tickers" - rows = await connector._api_get(path_url=path, limit_id=path, is_auth_required=False) - return await _normalize( - connector, rows, - lambda r: (r["currency_pair"], _mid(r.get("highest_bid"), r.get("lowest_ask"), r.get("last")), - _to_decimal(r.get("quote_volume"))), - ) - - -async def _okx(connector) -> Dict[str, Ticker]: - path = "/api/v5/market/tickers" - payload = await connector._api_get( - path_url=path, params={"instType": "SPOT"}, limit_id=path, is_auth_required=False - ) - rows = payload.get("data", []) if isinstance(payload, dict) else [] - return await _normalize( - connector, rows, - lambda r: (r["instId"], _mid(r.get("bidPx"), r.get("askPx"), r.get("last")), - _to_decimal(r.get("volCcy24h"))), - ) - - -async def _kucoin(connector) -> Dict[str, Ticker]: - path = "/api/v1/market/allTickers" - payload = await connector._api_get(path_url=path, limit_id=path, is_auth_required=False) - rows = payload.get("data", {}).get("ticker", []) if isinstance(payload, dict) else [] - return await _normalize( - connector, rows, - lambda r: (r.get("symbolName") or r.get("symbol"), - _mid(r.get("buy"), r.get("sell"), r.get("last")), - _to_decimal(r.get("volValue"))), - ) - - -async def _ascend_ex(connector) -> Dict[str, Ticker]: - path = "spot/ticker" - payload = await connector._api_get(path_url=path, limit_id=path, is_auth_required=False) - rows = payload.get("data", []) if isinstance(payload, dict) else [] - - def extract(r): - bid = r.get("bid", [None])[0] if isinstance(r.get("bid"), list) else None - ask = r.get("ask", [None])[0] if isinstance(r.get("ask"), list) else None - return r["symbol"], _mid(bid, ask, None), _to_decimal(r.get("volume")) - - return await _normalize(connector, rows, extract) +async def _load_symbol_map(connector, connector_name: str) -> Dict[str, str]: + """ + Force the connector's ``{exchange symbol -> hb pair}`` map to initialize, once per fetch. + + ``trading_pair_symbol_map()`` initializes itself lazily from a public endpoint, so this + works on a keyless, never-started data connector. It is loaded up front rather than per + row because ``_initialize_trading_pair_symbol_map`` swallows its exceptions: on failure the + map stays empty and every per-row lookup would retry the failed request under a lock and + then raise KeyError, leaving no trace of the real cause. + """ + try: + mapping = await asyncio.wait_for( + connector.trading_pair_symbol_map(), timeout=_SYMBOL_MAP_TIMEOUT + ) + except asyncio.TimeoutError as e: + raise TickerFetchError( + f"'{connector_name}': timed out loading the trading pair symbol map" + ) from e + except TickerFetchError: + raise + except Exception as e: # noqa: BLE001 + raise TickerFetchError( + f"'{connector_name}': failed to load the trading pair symbol map: {e}" + ) from e + + if not mapping: + # An empty map is the only signal that the exchange-info request failed, since + # _initialize_trading_pair_symbol_map logs and swallows the underlying exception. + raise TickerFetchError( + f"'{connector_name}': trading pair symbol map is empty. The exchange-info request " + f"failed (check the connector log for 'There was an error requesting exchange info')" + ) + return dict(mapping) + + +# ==================== Spec-driven adapter ==================== + +async def _request(connector, spec: TickerSpec) -> List[Dict[str, Any]]: + """Issue the spec's request through the connector and shape the payload into rows.""" + path = spec.path() if callable(spec.path) else spec.path + kwargs: Dict[str, Any] = {"path_url": path, "is_auth_required": False} + if spec.params: + kwargs["params"] = dict(spec.params) + if spec.data: + kwargs["data"] = dict(spec.data) + if spec.headers: + kwargs["headers"] = dict(spec.headers) + call = connector._api_post if spec.method == "POST" else connector._api_get + return spec.rows(await call(**kwargs)) + + +def _spec_extract(spec: TickerSpec): + """Build the per-row extractor described by a spec.""" + + def extract(row: Dict[str, Any]): + return ( + _get(row, spec.symbol), + _mid(_get(row, spec.bid), _get(row, spec.ask), _get(row, spec.last)), + _to_decimal(_get(row, spec.base_volume)), + _to_decimal(_get(row, spec.quote_volume)), + ) + + return extract + + +# ==================== Hand-written adapters ==================== +# Signature: async def adapter(connector, symbol_map) -> Dict[str, Ticker] + +async def _hyperliquid(connector, symbol_map: Mapping[str, str]) -> Dict[str, Ticker]: + """Hyperliquid spot. + + ``spotMetaAndAssetCtxs`` returns ``[meta, assetCtxs]``, and the two are NOT the same length + (universe holds the tradable pairs, assetCtxs is longer), so they must not be zipped. Each + ctx carries ``coin``, which is exactly the key the connector's symbol map is built on. + """ + payload = await connector._api_post(path_url="/info", data={"type": "spotMetaAndAssetCtxs"}) + ctxs = payload[1] if isinstance(payload, list) and len(payload) > 1 else [] + rows = [c for c in ctxs if isinstance(c, dict)] + + def extract(row): + return ( + row.get("coin"), + _mid(None, None, row.get("midPx")) or _to_decimal(row.get("markPx")), + _to_decimal(row.get("dayBaseVlm")), + _to_decimal(row.get("dayNtlVlm")), + ) + + return _normalize(symbol_map, rows, extract, "hyperliquid") + + +async def _hyperliquid_perpetual(connector, symbol_map: Mapping[str, str]) -> Dict[str, Ticker]: + """Hyperliquid perpetuals. + + ``metaAndAssetCtxs`` returns ``[meta, assetCtxs]`` of equal length, index-aligned, and the + ctxs carry no symbol of their own -- zipping against ``meta["universe"]`` is the only join. + A length mismatch would silently attach every price to the wrong market, so it raises. + """ + payload = await connector._api_post(path_url="/info", data={"type": "metaAndAssetCtxs"}) + meta = payload[0].get("universe", []) if isinstance(payload, list) and payload else [] + ctxs = payload[1] if isinstance(payload, list) and len(payload) > 1 else [] + if len(meta) != len(ctxs): + raise TickerFetchError( + f"hyperliquid_perpetual: universe/assetCtxs length mismatch " + f"({len(meta)} vs {len(ctxs)}); refusing to zip misaligned prices" + ) + rows = [ + {**ctx, "_name": entry.get("name")} + for entry, ctx in zip(meta, ctxs) + if isinstance(entry, dict) and isinstance(ctx, dict) + ] + + def extract(row): + return ( + row.get("_name"), + _mid(None, None, row.get("midPx")) or _to_decimal(row.get("markPx")), + _to_decimal(row.get("dayBaseVlm")), + _to_decimal(row.get("dayNtlVlm")), + ) + + return _normalize(symbol_map, rows, extract, "hyperliquid_perpetual") + + +TICKER_ADAPTERS: Dict[ + str, Callable[[Any, Mapping[str, str]], Awaitable[Dict[str, Ticker]]] +] = { + "hyperliquid": _hyperliquid, + "hyperliquid_perpetual": _hyperliquid_perpetual, +} # ==================== Generic adapter (price-only, any connector) ==================== @@ -192,85 +550,123 @@ def _heuristic_rows(raw: Any) -> List[Dict[str, Any]]: rows = [] for key, value in container.items(): if isinstance(value, dict): - rows.append({**value, "_sym": key}) + rows.append({**value, "_key": key}) return rows return [] -async def _generic(connector, connector_name: str) -> Dict[str, Ticker]: +def _has_bulk_last_traded_prices(connector) -> bool: + """True when the connector overrides get_last_traded_prices with a single bulk call.""" + try: + from hummingbot.connector.exchange_base import ExchangeBase + + return getattr(type(connector), "get_last_traded_prices", None) is not getattr( + ExchangeBase, "get_last_traded_prices", None + ) + except Exception: # noqa: BLE001 + return False + + +async def _generic(connector, connector_name: str, symbol_map: Mapping[str, str]) -> Dict[str, Ticker]: """ - Price-only adapter for any connector without a dedicated one. + Price-only adapter for any connector without a spec or dedicated adapter. - Prefers the single bulk ``get_all_pairs_prices()`` call (heuristically parsed and mapped - through the connector's symbol map). If that endpoint is unavailable, falls back to - ``get_last_traded_prices()`` over the connector's known pairs, whose keys are already in - Hummingbot format. Volume is not available on this path. + Prefers the single bulk ``get_all_pairs_prices()`` call, heuristically parsed and mapped + through the symbol map. If that endpoint is unavailable, falls back to + ``get_last_traded_prices()`` over the connector's known pairs. + + Volume is deliberately left unset on this path: guessing a volume field name risks silently + mixing base and quote units, which is worse than reporting nothing. """ # 1. Bulk all-pairs endpoint with heuristic parsing. try: - raw = await connector.get_all_pairs_prices() - rows = _heuristic_rows(raw) + rows = _heuristic_rows(await connector.get_all_pairs_prices()) if rows: - tickers = await _normalize( - connector, rows, - lambda r: (_first(r, _SYMBOL_KEYS) or r.get("_sym"), - _mid(_first(r, _BID_KEYS), _first(r, _ASK_KEYS), _first(r, _LAST_KEYS)), - None), + tickers = _normalize( + symbol_map, rows, + lambda r: ( + _first(r, _SYMBOL_KEYS) or r.get("_key"), + _mid(_first(r, _BID_KEYS), _first(r, _ASK_KEYS), _first(r, _LAST_KEYS)), + None, + None, + ), + connector_name, ) if tickers: return tickers except (NotImplementedError, AttributeError): pass + except TickerFetchError: + raise except Exception as e: # noqa: BLE001 logger.debug(f"Generic all-pairs fetch failed for '{connector_name}': {e}") - # 2. Fallback: last traded prices over known pairs (keys already BASE-QUOTE). - try: - pairs = list(getattr(connector, "trading_rules", {}).keys()) - if not pairs: - return {} - prices = await connector.get_last_traded_prices(pairs) - now = time.time() - return { - pair: Ticker(price=dec, volume=None, timestamp=now) - for pair, price in prices.items() - if (dec := _to_decimal(price)) is not None and dec > 0 - } - except Exception as e: # noqa: BLE001 - logger.debug(f"Generic last-traded fetch failed for '{connector_name}': {e}") - return {} + # 2. Fallback: last traded prices over the known pairs (keys are already BASE-QUOTE). + # The pairs come from the symbol map, not connector.trading_rules, which stays empty + # forever on a keyless connector because _update_trading_rules is never awaited. + pairs = sorted(set(symbol_map.values())) + if not pairs: + raise TickerFetchError(f"'{connector_name}': no trading pairs known") + if not _has_bulk_last_traded_prices(connector) and len(pairs) > _MAX_LAST_TRADED_PAIRS: + raise TickerUnsupportedError( + f"'{connector_name}' has no bulk ticker endpoint; pricing its {len(pairs)} pairs " + f"would take one HTTP request each. Add a TickerSpec for this connector." + ) + + prices = await connector.get_last_traded_prices(pairs) + now = time.time() + return { + pair: Ticker(price=dec, timestamp=now) + for pair, price in prices.items() + if (dec := _to_decimal(price)) is not None and dec > 0 + } -# Registry of dedicated price+volume adapters, verified against each exchange's spot endpoint. -# Perpetual variants are intentionally NOT reused here: their ticker endpoints differ (different -# rate-limit ids, instrument types and volume scaling), so they are handled by the generic -# price-only adapter above via each connector's own methods. Any connector not listed here uses -# the generic adapter. -TICKER_ADAPTERS: Dict[str, Callable[[Any], Awaitable[Dict[str, Ticker]]]] = { - "binance": _binance, - "gate_io": _gate_io, - "okx": _okx, - "kucoin": _kucoin, - "ascend_ex": _ascend_ex, -} +# ==================== Entry point ==================== + +async def _fetch(connector, connector_name: str) -> Dict[str, Ticker]: + """Load the symbol map, then dispatch to the adapter, spec or generic path.""" + symbol_map = await _load_symbol_map(connector, connector_name) + name = _canonical(connector_name) + + adapter = TICKER_ADAPTERS.get(name) + if adapter is not None: + return await adapter(connector, symbol_map) + + spec = TICKER_SPECS.get(name) + if spec is not None: + rows = await _request(connector, spec) + return _normalize(symbol_map, rows, _spec_extract(spec), connector_name) + return await _generic(connector, connector_name, symbol_map) -async def fetch_tickers(connector, connector_name: str) -> Dict[str, Ticker]: + +async def fetch_tickers( + connector, connector_name: str, *, raise_on_error: bool = False +) -> Dict[str, Ticker]: """ Fetch and normalize tickers for one connector. Returns ``{hb_pair -> Ticker}``. - Uses the dedicated adapter (price + 24h volume) when one is registered, otherwise the - generic price-only adapter. Never raises: failures and timeouts are logged and yield an - empty dict so one exchange cannot break the collection cycle. + Uses the dedicated adapter or spec when one is registered (price plus 24h base/quote + volume), otherwise the generic price-only adapter. + + Args: + raise_on_error: False (background collection) logs failures and returns an empty dict so + one exchange cannot break the collection cycle. True (on-demand requests) raises + TickerUnsupportedError or TickerFetchError so the caller can report why. """ - adapter = TICKER_ADAPTERS.get(connector_name) try: - if adapter is not None: - return await asyncio.wait_for(adapter(connector), timeout=_FETCH_TIMEOUT) - return await asyncio.wait_for(_generic(connector, connector_name), timeout=_FETCH_TIMEOUT) + reason = UNSUPPORTED_TICKER_CONNECTORS.get(_canonical(connector_name)) + if reason: + raise TickerUnsupportedError(f"'{connector_name}' {reason}") + return await asyncio.wait_for(_fetch(connector, connector_name), timeout=_FETCH_TIMEOUT) except asyncio.TimeoutError: + if raise_on_error: + raise TickerFetchError(f"Ticker fetch timed out for '{connector_name}'") logger.warning(f"Ticker fetch timed out for '{connector_name}'") return {} except Exception as e: # noqa: BLE001 - one exchange failing must not break the cycle + if raise_on_error: + raise e if isinstance(e, TickerFetchError) else TickerFetchError(str(e)) from e logger.warning(f"Failed to fetch tickers for '{connector_name}': {e}") return {} From 9c3adf533f4475726ff28e10f1a22bc7b05bcc25 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 28 Jul 2026 15:10:32 +0300 Subject: [PATCH 4/9] (feat) add unknown connector error --- services/unified_connector_service.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/services/unified_connector_service.py b/services/unified_connector_service.py index 09265770..48f0cedf 100644 --- a/services/unified_connector_service.py +++ b/services/unified_connector_service.py @@ -35,6 +35,13 @@ logger = logging.getLogger(__name__) +class UnknownConnectorError(ValueError): + """Raised when a connector name is not a known Hummingbot connector. + + Subclasses ValueError so existing ``except ValueError`` handlers keep working. + """ + + class UnifiedConnectorService: """ Single source of truth for ALL connector instances. @@ -184,6 +191,14 @@ def is_trading_connector_initialized( # Data Connector Management (non-authenticated, shared) # ========================================================================= + def is_known_connector(self, connector_name: str) -> bool: + """True if the name is a known Hummingbot exchange connector. + + Gateway network connectors (e.g. 'solana-mainnet-beta') are not in AllConnectorSettings + and therefore return False. + """ + return connector_name in self._conn_settings + def get_data_connector(self, connector_name: str) -> ConnectorBase: """ Get or create a non-authenticated data connector for public market data. @@ -689,7 +704,7 @@ def _create_data_connector(self, connector_name: str) -> ConnectorBase: """Create a non-authenticated data connector.""" conn_setting = self._conn_settings.get(connector_name) if not conn_setting: - raise ValueError(f"Connector {connector_name} not found") + raise UnknownConnectorError(f"Connector {connector_name} not found") # Get config keys but don't use real API keys connector_config = AllConnectorSettings.get_connector_config_keys(connector_name) From fe8232ace428c5c59adc2ae4cc0091f9ffc5104f Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 28 Jul 2026 15:10:42 +0300 Subject: [PATCH 5/9] (feat) add ticker config --- services/market_data_service.py | 127 ++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 7 deletions(-) diff --git a/services/market_data_service.py b/services/market_data_service.py index 6758466c..e9880185 100644 --- a/services/market_data_service.py +++ b/services/market_data_service.py @@ -18,7 +18,8 @@ from hummingbot.data_feed.candles_feed.candles_factory import CandlesFactory, UnsupportedConnectorException from hummingbot.data_feed.candles_feed.data_types import CandlesConfig -from services.ticker_sources import Ticker, fetch_tickers +from services.ticker_sources import Ticker, TickerFetchError, fetch_tickers +from services.unified_connector_service import UnknownConnectorError from utils.rate_finder import find_rate logger = logging.getLogger(__name__) @@ -50,6 +51,8 @@ def __init__( cleanup_interval: int = 300, feed_timeout: int = 600, ticker_update_interval: int = 30, + ticker_max_age: int = 60, + ticker_subscription_ttl: int = 600, ): """ Initialize the MarketDataService. @@ -60,12 +63,17 @@ def __init__( cleanup_interval: How often to run feed cleanup (seconds, default: 5 minutes) feed_timeout: How long to keep unused feeds alive (seconds, default: 10 minutes) ticker_update_interval: How often to refresh tickers from connected exchanges (seconds) + ticker_max_age: Max age of cached tickers before an on-demand request refetches them + ticker_subscription_ttl: How long a ticker-only connector stays in the background + refresh cycle after its last on-demand request (seconds) """ self._connector_service = connector_service self._quote_token = quote_token self._cleanup_interval = cleanup_interval self._feed_timeout = feed_timeout self._ticker_update_interval = ticker_update_interval + self._ticker_max_age = ticker_max_age + self._ticker_subscription_ttl = ticker_subscription_ttl # Candle feeds management self._candle_feeds: Dict[str, Any] = {} @@ -79,6 +87,13 @@ def __init__( self._prices: Dict[str, Decimal] = {} self._external_prices: Dict[str, Decimal] = {} + # On-demand ticker fetching: last successful fetch per connector, a per-connector lock + # that collapses concurrent requests into a single upstream call, and the last time each + # connector was explicitly requested (drives the background subscription TTL). + self._ticker_updated_at: Dict[str, float] = {} + self._ticker_locks: Dict[str, asyncio.Lock] = {} + self._ticker_requests: Dict[str, float] = {} + # Background tasks self._cleanup_task: Optional[asyncio.Task] = None self._ticker_task: Optional[asyncio.Task] = None @@ -134,6 +149,9 @@ def stop(self): self._feed_configs.clear() self._tickers.clear() self._prices.clear() + self._ticker_updated_at.clear() + self._ticker_requests.clear() + self._ticker_locks.clear() logger.info("MarketDataService stopped") @@ -623,22 +641,102 @@ def get_rate_for_connector( # ==================== Ticker Collection ==================== + async def fetch_connector_tickers( + self, + connector_name: str, + *, + max_age: Optional[float] = None, + force: bool = False + ) -> Dict[str, Ticker]: + """ + Get one connector's tickers, fetching on demand when the cache is missing or stale. + + This works without API keys: ``get_best_connector_for_market`` falls back to a keyless + public data connector, which is then also picked up by the background refresh cycle. + + Args: + connector_name: Exchange connector name + max_age: Accept cached tickers up to this age in seconds (defaults to ticker_max_age) + force: Ignore the cache and always fetch + + Returns: + Mapping of trading pair to Ticker + + Raises: + UnknownConnectorError: the name is not a known Hummingbot connector + TickerUnsupportedError: the connector cannot serve public tickers without credentials + TickerFetchError: the fetch or parse failed + """ + if not self._connector_service.is_known_connector(connector_name): + raise UnknownConnectorError(f"Connector {connector_name} not found") + + # Mark the connector as actively requested so the background loop keeps it warm. + self._ticker_requests[connector_name] = time.time() + max_age = self._ticker_max_age if max_age is None else max_age + + if not force and self._is_ticker_fresh(connector_name, max_age): + return self._tickers[connector_name] + + lock = self._ticker_locks.setdefault(connector_name, asyncio.Lock()) + async with lock: + # A concurrent request may have refreshed the cache while we waited on the lock. + if not force and self._is_ticker_fresh(connector_name, max_age): + return self._tickers[connector_name] + + connector = self._connector_service.get_best_connector_for_market(connector_name) + if connector is None: + raise TickerFetchError(f"No connector available for '{connector_name}'") + + tickers = await fetch_tickers(connector, connector_name, raise_on_error=True) + self._tickers[connector_name] = tickers + self._ticker_updated_at[connector_name] = time.time() + self._rebuild_price_pool() + logger.info(f"On-demand ticker fetch for '{connector_name}': {len(tickers)} pairs") + return tickers + + def _is_ticker_fresh(self, connector_name: str, max_age: float) -> bool: + """True if there are cached tickers for the connector and they are younger than max_age.""" + if not self._tickers.get(connector_name): + return False + return time.time() - self._ticker_updated_at.get(connector_name, 0.0) <= max_age + + def ticker_updated_at(self, connector_name: str) -> Optional[float]: + """Timestamp of the last successful ticker fetch for a connector, or None.""" + return self._ticker_updated_at.get(connector_name) + def _connected_connector_names(self) -> List[str]: - """Unique connector names currently connected (trading connectors + started data connectors). + """Unique connector names to collect tickers for. - Every connected exchange is collected: dedicated adapters provide price+volume, and the - generic adapter provides price for the rest. Paper-trade connectors are skipped. + Trading connectors are always collected. Data connectors are too, except ticker-only + ones whose last on-demand request is older than the subscription TTL: without that + expiry, one request per exchange would permanently add every venue to each cycle. + Paper-trade connectors are skipped. """ names = set() for account_connectors in self._connector_service.get_all_trading_connectors().values(): names.update(account_connectors.keys()) - names.update(self._connector_service._data_connectors.keys()) + + now = time.time() + for name in self._connector_service._data_connectors: + last_request = self._ticker_requests.get(name) + # Data connectors created for other purposes (order books, trading rules) were never + # requested for tickers and are always collected. + if last_request is None or now - last_request <= self._ticker_subscription_ttl: + names.add(name) + return [n for n in names if "paper_trade" not in n] async def _collect_all_tickers(self): """Fetch tickers from every connected exchange concurrently and rebuild the price pool.""" connector_names = self._connected_connector_names() + + # Drop connectors that fell out of the collection set so no one is served stale data. + for stale in [n for n in self._tickers if n not in connector_names]: + self._tickers.pop(stale, None) + self._ticker_updated_at.pop(stale, None) + if not connector_names: + self._rebuild_price_pool() return async def _fetch(name: str) -> Tuple[str, Dict[str, Ticker]]: @@ -651,6 +749,7 @@ async def _fetch(name: str) -> Tuple[str, Dict[str, Ticker]]: *[_fetch(name) for name in connector_names], return_exceptions=True ) + now = time.time() for result in results: if isinstance(result, Exception): logger.warning(f"Ticker collection task failed: {result}") @@ -658,6 +757,7 @@ async def _fetch(name: str) -> Tuple[str, Dict[str, Ticker]]: name, tickers = result if tickers: self._tickers[name] = tickers + self._ticker_updated_at[name] = now self._rebuild_price_pool() @@ -681,8 +781,21 @@ def _rebuild_price_pool(self): @staticmethod def _is_more_liquid(candidate: Ticker, current: Ticker) -> bool: - """True if candidate should replace current (higher volume, treating None as 0).""" - return (candidate.volume or Decimal("0")) > (current.volume or Decimal("0")) + """True if candidate should replace current as the price source for a pair. + + Quote volume is the only cross-exchange comparable measure (the same pair implies the + same quote token), and it is populated whenever the exchange reported any volume at + all, so exchanges that only report base volume still take part in the comparison + instead of always ranking as zero. + """ + candidate_volume, current_volume = candidate.quote_volume, current.quote_volume + if candidate_volume is not None and current_volume is not None: + return candidate_volume > current_volume + if candidate_volume is not None: + return True # a known volume beats an unknown one + if current_volume is not None: + return False + return candidate.timestamp > current.timestamp # both unknown: prefer the fresher one async def _ticker_collection_loop(self): """Background task that periodically refreshes tickers from connected exchanges.""" From b8f0213d5adc82c189dd2ee960e5340baa78ef4f Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 28 Jul 2026 15:10:57 +0300 Subject: [PATCH 6/9] (feat) use unwrap token symbol --- utils/rate_finder.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/utils/rate_finder.py b/utils/rate_finder.py index bf39c2fb..d6e9af51 100644 --- a/utils/rate_finder.py +++ b/utils/rate_finder.py @@ -8,14 +8,14 @@ any intermediate pair that shares the base or quote asset. The helper functions (``combine_to_hb_trading_pair``, ``split_hb_trading_pair`` and -``normalize_token_symbol``) are reused from hummingbot to keep trading-pair formatting and +``unwrap_token_symbol``) are reused from hummingbot to keep trading-pair formatting and token normalization identical to the rest of the stack. """ from decimal import Decimal from typing import Dict, Optional from hummingbot.connector.utils import combine_to_hb_trading_pair, split_hb_trading_pair -from hummingbot.core.rate_oracle.utils import normalize_token_symbol +from hummingbot.core.rate_oracle.utils import unwrap_token_symbol def find_rate(prices: Dict[str, Decimal], pair: str) -> Optional[Decimal]: @@ -39,8 +39,8 @@ def find_rate(prices: Dict[str, Decimal], pair: str) -> Optional[Decimal]: if pair in prices: return prices[pair] base, quote = split_hb_trading_pair(trading_pair=pair) - base = normalize_token_symbol(base) - quote = normalize_token_symbol(quote) + base = unwrap_token_symbol(base) + quote = unwrap_token_symbol(quote) if base == quote: return Decimal("1") # Re-check the direct pair after normalizing (e.g. HBOT-USD -> HBOT-USDT) before From 4b63591f48ae15fd2ca28036e7680f4b19f09afe Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 28 Jul 2026 15:11:21 +0300 Subject: [PATCH 7/9] (feat) add configs for freshness and ttl --- config.py | 9 +++++++++ main.py | 2 ++ 2 files changed, 11 insertions(+) diff --git a/config.py b/config.py index d3e3d60a..83fd3ae5 100644 --- a/config.py +++ b/config.py @@ -59,6 +59,15 @@ class MarketDataSettings(BaseSettings): default=30, description="How often to refresh tickers from connected exchanges in seconds" ) + ticker_max_age: int = Field( + default=60, + description="Max age of cached tickers before an on-demand request refetches them, in seconds" + ) + ticker_subscription_ttl: int = Field( + default=600, + description="How long a ticker-only connector stays in the background refresh cycle " + "after its last request, in seconds" + ) model_config = SettingsConfigDict(env_prefix="MARKET_DATA_", extra="ignore") diff --git a/main.py b/main.py index f05dbab7..e086e107 100644 --- a/main.py +++ b/main.py @@ -197,6 +197,8 @@ async def lifespan(app: FastAPI): cleanup_interval=settings.market_data.cleanup_interval, feed_timeout=settings.market_data.feed_timeout, ticker_update_interval=settings.market_data.ticker_update_interval, + ticker_max_age=settings.market_data.ticker_max_age, + ticker_subscription_ttl=settings.market_data.ticker_subscription_ttl, ) # Connector trade-volume telemetry resolves rates through the ticker pool instead of the # legacy RateOracle singleton. From be884eebbf7c299b81d2fd527c4ccf445779ce67 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 28 Jul 2026 18:21:38 +0300 Subject: [PATCH 8/9] (feat) unify routes and add hip3 --- config.py | 6 ++ models/__init__.py | 6 +- models/market_data.py | 25 ++++---- routers/market_data.py | 108 +++++++++++++++++++------------- services/market_data_service.py | 98 +++++++++++++++++++++++------ services/ticker_sources.py | 96 ++++++++++++++++++++++++---- 6 files changed, 251 insertions(+), 88 deletions(-) diff --git a/config.py b/config.py index 83fd3ae5..b674863a 100644 --- a/config.py +++ b/config.py @@ -68,6 +68,12 @@ class MarketDataSettings(BaseSettings): description="How long a ticker-only connector stays in the background refresh cycle " "after its last request, in seconds" ) + hyperliquid_hip3_interval: int = Field( + default=120, + description="How often to refresh Hyperliquid HIP-3 (builder-deployed perp dex) tickers, " + "in seconds. These need one request per dex (~10 total), so they refresh on a " + "slower cadence than the main perp dex. Set to 0 to exclude HIP-3 markets." + ) model_config = SettingsConfigDict(env_prefix="MARKET_DATA_", extra="ignore") diff --git a/models/__init__.py b/models/__init__.py index 145492bd..eb3674d8 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -118,10 +118,8 @@ ActiveFeedInfo, ActiveFeedsResponse, AddTradingPairRequest, - AllTickersResponse, CandleData, CandlesResponse, - ConnectorTickersResponse, FundingInfoRequest, FundingInfoResponse, MarketDataSettings, @@ -143,6 +141,7 @@ SingleRateResponse, SupportedOrderTypesResponse, TickerInfo, + TickersResponse, TradingPairResponse, TradingRulesResponse, VolumeForPriceRequest, @@ -266,8 +265,7 @@ "TradingPairResponse", # Ticker & rate models "TickerInfo", - "ConnectorTickersResponse", - "AllTickersResponse", + "TickersResponse", "RateRequest", "RatesResponse", "SingleRateResponse", diff --git a/models/market_data.py b/models/market_data.py index 34277bd8..3018c12c 100644 --- a/models/market_data.py +++ b/models/market_data.py @@ -1,5 +1,4 @@ from datetime import datetime -from decimal import Decimal from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field @@ -207,21 +206,23 @@ class TickerInfo(BaseModel): timestamp: float = Field(description="Collection timestamp") -class ConnectorTickersResponse(BaseModel): - """Tickers for a single connector.""" - connector: str = Field(description="Connector name") - count: int = Field(description="Number of tickers") - tickers: Dict[str, TickerInfo] = Field(description="Trading pair to ticker mapping") - updated_at: Optional[float] = Field( - default=None, description="Unix timestamp of the last successful fetch" - ) - - -class AllTickersResponse(BaseModel): +class TickersResponse(BaseModel): """Tickers grouped by connector.""" tickers: Dict[str, Dict[str, TickerInfo]] = Field( description="Connector to {trading pair: ticker} mapping" ) + counts: Dict[str, int] = Field( + default_factory=dict, description="Number of tickers per connector" + ) + updated_at: Dict[str, Optional[float]] = Field( + default_factory=dict, + description="Unix timestamp of the last successful fetch, per connector" + ) + errors: Dict[str, str] = Field( + default_factory=dict, + description="Connectors that could not be fetched, with the reason. Present only when " + "some requested connectors succeeded and others failed" + ) class RateRequest(BaseModel): diff --git a/routers/market_data.py b/routers/market_data.py index b812938e..2c14ed6f 100644 --- a/routers/market_data.py +++ b/routers/market_data.py @@ -1,7 +1,7 @@ import asyncio import logging import time -from typing import Optional +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request from hummingbot.data_feed.candles_feed.candles_factory import CandlesFactory, UnsupportedConnectorException @@ -11,8 +11,6 @@ from deps import get_market_data_service from models import ( AddTradingPairRequest, - AllTickersResponse, - ConnectorTickersResponse, FundingInfoRequest, FundingInfoResponse, OrderBookLevel, @@ -30,6 +28,7 @@ RemoveTradingPairRequest, SingleRateResponse, TickerInfo, + TickersResponse, TradingPairResponse, VolumeForPriceRequest, VWAPForVolumeRequest, @@ -286,20 +285,37 @@ def _tickers_to_info(tickers) -> dict: return {pair: TickerInfo(**t.to_dict()) for pair, t in tickers.items()} -@router.get("/tickers", response_model=AllTickersResponse) -async def get_all_tickers( - market_data_manager: MarketDataService = Depends(get_market_data_service) -): - """Get the latest collected tickers from every connected exchange, grouped by connector.""" - all_tickers = market_data_manager.get_tickers() - return AllTickersResponse( - tickers={name: _tickers_to_info(tickers) for name, tickers in all_tickers.items()} +def _requested_connectors(connectors: Optional[List[str]]) -> List[str]: + """Parse the connector filter, accepting both ?connectors=a,b and ?connectors=a&connectors=b.""" + names = [] + for value in connectors or []: + names.extend(part.strip() for part in value.split(",")) + return list(dict.fromkeys(n for n in names if n)) # de-duplicated, order preserved + + +def _build_tickers_response( + tickers_by_connector: dict, + errors: dict, + market_data_manager: MarketDataService, +) -> TickersResponse: + grouped = {name: _tickers_to_info(t) for name, t in tickers_by_connector.items()} + return TickersResponse( + tickers=grouped, + counts={name: len(t) for name, t in grouped.items()}, + updated_at={ + name: market_data_manager.ticker_updated_at(name) for name in grouped + }, + errors={name: str(exc) for name, exc in errors.items()}, ) -@router.get("/tickers/{connector_name}", response_model=ConnectorTickersResponse) -async def get_connector_tickers( - connector_name: str, +@router.get("/tickers", response_model=TickersResponse) +async def get_tickers( + connectors: Optional[List[str]] = Query( + None, + description="Restrict to these connectors. Accepts a comma-separated list or the " + "parameter repeated. Omit to return the whole collected pool." + ), refresh: bool = Query(False, description="Force a fresh fetch, ignoring the cache"), max_age: Optional[float] = Query( None, ge=0, description="Accept cached tickers up to this age in seconds" @@ -307,38 +323,46 @@ async def get_connector_tickers( market_data_manager: MarketDataService = Depends(get_market_data_service) ): """ - Get the tickers for a single connector, with 24h base and quote volume where available. + Get tickers grouped by connector, with 24h base and quote volume where available. - Tickers are fetched on demand through a keyless public data connector when the cache is - missing or stale, so this works for exchanges no API keys are configured for. The connector - then joins the background refresh cycle. + Without ``connectors`` this returns the collected pool as-is. Naming connectors fetches + them on demand (concurrently) through keyless public data connectors when the cache is + missing or stale, so it works for exchanges no API keys are configured for; those + connectors then join the background refresh cycle. + + A connector that fails does not remove the others from the response: it is reported under + ``errors`` alongside the successful results. An error status is returned only when nothing + could be served at all. """ + names = _requested_connectors(connectors) + + # No filter and no refresh: a plain read of the pool, no requests made. + if not names and not refresh: + return _build_tickers_response(market_data_manager.get_tickers(), {}, market_data_manager) + + targets = names or market_data_manager.collected_connector_names() + if not targets: + return _build_tickers_response({}, {}, market_data_manager) + try: - tickers = await market_data_manager.fetch_connector_tickers( - connector_name, max_age=max_age, force=refresh - ) - except UnknownConnectorError: - raise HTTPException(status_code=404, detail=f"Connector '{connector_name}' not found") - except TickerUnsupportedError as e: - # Known connector, but no amount of retrying will make it serve public tickers. - raise HTTPException(status_code=400, detail=str(e)) - except TickerFetchError as e: - raise HTTPException( - status_code=502, detail=f"Failed to fetch tickers from '{connector_name}': {e}" + tickers, errors = await market_data_manager.fetch_tickers_for( + targets, max_age=max_age, force=refresh ) - except Exception as e: - logger.error( - f"Unexpected error fetching tickers for {connector_name}: {e}", exc_info=True - ) - raise HTTPException(status_code=500, detail=str(e)) - - info = _tickers_to_info(tickers) - return ConnectorTickersResponse( - connector=connector_name, - count=len(info), - tickers=info, - updated_at=market_data_manager.ticker_updated_at(connector_name), - ) + except UnknownConnectorError as e: + raise HTTPException(status_code=404, detail=str(e)) + + # Everything requested failed, so report why instead of an empty, seemingly-fine 200. + if errors and not tickers: + detail = "; ".join(f"{name}: {exc}" for name, exc in sorted(errors.items())) + if all(isinstance(exc, TickerUnsupportedError) for exc in errors.values()): + # Known connectors that can never serve public tickers; retrying will not help. + raise HTTPException(status_code=400, detail=detail) + if all(isinstance(exc, TickerFetchError) for exc in errors.values()): + raise HTTPException(status_code=502, detail=f"Failed to fetch tickers: {detail}") + logger.error(f"Unexpected error fetching tickers for {targets}: {detail}") + raise HTTPException(status_code=500, detail=detail) + + return _build_tickers_response(tickers, errors, market_data_manager) @router.post("/rates", response_model=RatesResponse) diff --git a/services/market_data_service.py b/services/market_data_service.py index e9880185..594723d2 100644 --- a/services/market_data_service.py +++ b/services/market_data_service.py @@ -18,12 +18,23 @@ from hummingbot.data_feed.candles_feed.candles_factory import CandlesFactory, UnsupportedConnectorException from hummingbot.data_feed.candles_feed.data_types import CandlesConfig -from services.ticker_sources import Ticker, TickerFetchError, fetch_tickers +from services.ticker_sources import Ticker, TickerFetchError, TickerUnsupportedError, fetch_tickers from services.unified_connector_service import UnknownConnectorError from utils.rate_finder import find_rate logger = logging.getLogger(__name__) +# Connector name fragments that must never contribute market data. Paper-trade connectors +# simulate fills, and test networks trade worthless assets at arbitrary prices, so letting +# either into the ticker pool would corrupt cross-rate resolution and portfolio valuation. +_NON_MARKET_CONNECTOR_MARKERS = ("paper_trade", "testnet", "sandbox") + + +def is_market_data_connector(connector_name: str) -> bool: + """False for paper-trade and test-network connectors, whose prices are not real markets.""" + lowered = connector_name.lower() + return not any(marker in lowered for marker in _NON_MARKET_CONNECTOR_MARKERS) + class FeedType(Enum): """Types of market data feeds that can be managed.""" @@ -612,20 +623,8 @@ def prices(self) -> Dict[str, Decimal]: """The merged price pool (ticker prices plus external prices).""" return self._prices - def get_ticker(self, connector_name: str, trading_pair: str) -> Optional[Ticker]: - """Get a single collected ticker for a connector/pair, or None.""" - return self._tickers.get(connector_name, {}).get(trading_pair) - - def get_tickers(self, connector_name: Optional[str] = None) -> Dict[str, Dict[str, Ticker]]: - """ - Get collected tickers. - - Args: - connector_name: If provided, return only that connector's tickers (wrapped in a - single-key dict); otherwise return tickers for all connectors. - """ - if connector_name is not None: - return {connector_name: self._tickers.get(connector_name, {})} + def get_tickers(self) -> Dict[str, Dict[str, Ticker]]: + """Get the collected ticker pool, as ``{connector: {trading_pair: Ticker}}``.""" return self._tickers def get_rate_for_connector( @@ -670,6 +669,14 @@ async def fetch_connector_tickers( if not self._connector_service.is_known_connector(connector_name): raise UnknownConnectorError(f"Connector {connector_name} not found") + # Refused rather than merely skipped during collection: serving them here would cache + # the result and pull it into the price pool through _rebuild_price_pool. + if not is_market_data_connector(connector_name): + raise TickerUnsupportedError( + f"'{connector_name}' is a paper-trade or test-network connector; its prices are " + f"not real market data and are excluded from the ticker pool" + ) + # Mark the connector as actively requested so the background loop keeps it warm. self._ticker_requests[connector_name] = time.time() max_age = self._ticker_max_age if max_age is None else max_age @@ -683,7 +690,18 @@ async def fetch_connector_tickers( if not force and self._is_ticker_fresh(connector_name, max_age): return self._tickers[connector_name] - connector = self._connector_service.get_best_connector_for_market(connector_name) + try: + connector = self._connector_service.get_best_connector_for_market(connector_name) + except UnknownConnectorError: + raise + except Exception as e: + # Building a keyless connector can fail before any request is made: an optional + # dependency is missing (dydx_v4 needs v4_proto, vertex needs eip712_structs) or + # the connector demands credentials in its constructor. Retrying never helps. + raise TickerUnsupportedError( + f"'{connector_name}' cannot be instantiated for public market data: " + f"{type(e).__name__}: {e}" + ) from e if connector is None: raise TickerFetchError(f"No connector available for '{connector_name}'") @@ -694,6 +712,49 @@ async def fetch_connector_tickers( logger.info(f"On-demand ticker fetch for '{connector_name}': {len(tickers)} pairs") return tickers + async def fetch_tickers_for( + self, + connector_names: List[str], + *, + max_age: Optional[float] = None, + force: bool = False + ) -> Tuple[Dict[str, Dict[str, Ticker]], Dict[str, BaseException]]: + """ + Fetch several connectors' tickers concurrently. + + Returns ``(tickers_by_connector, errors_by_connector)``. One exchange failing never + removes another's data from the result, so failures are reported per connector rather + than raised: the caller decides whether a partial result is still useful, and maps the + returned exceptions to a status code. + + Raises: + UnknownConnectorError: if any requested name is not a known connector. Unlike a + fetch failure this is a caller mistake, so it fails fast before any request. + """ + unknown = [n for n in connector_names if not self._connector_service.is_known_connector(n)] + if unknown: + raise UnknownConnectorError(f"Connectors not found: {', '.join(sorted(unknown))}") + + async def _fetch(name: str): + return name, await self.fetch_connector_tickers(name, max_age=max_age, force=force) + + results = await asyncio.gather( + *[_fetch(name) for name in connector_names], return_exceptions=True + ) + + tickers: Dict[str, Dict[str, Ticker]] = {} + errors: Dict[str, BaseException] = {} + for name, result in zip(connector_names, results): + if isinstance(result, BaseException): + errors[name] = result + else: + tickers[name] = result[1] + return tickers, errors + + def collected_connector_names(self) -> List[str]: + """Connector names currently in the background ticker collection cycle.""" + return self._connected_connector_names() + def _is_ticker_fresh(self, connector_name: str, max_age: float) -> bool: """True if there are cached tickers for the connector and they are younger than max_age.""" if not self._tickers.get(connector_name): @@ -710,7 +771,8 @@ def _connected_connector_names(self) -> List[str]: Trading connectors are always collected. Data connectors are too, except ticker-only ones whose last on-demand request is older than the subscription TTL: without that expiry, one request per exchange would permanently add every venue to each cycle. - Paper-trade connectors are skipped. + Paper-trade and test-network connectors are always skipped, even when an account has + one configured for trading. """ names = set() for account_connectors in self._connector_service.get_all_trading_connectors().values(): @@ -724,7 +786,7 @@ def _connected_connector_names(self) -> List[str]: if last_request is None or now - last_request <= self._ticker_subscription_ttl: names.add(name) - return [n for n in names if "paper_trade" not in n] + return [n for n in names if is_market_data_connector(n)] async def _collect_all_tickers(self): """Fetch tickers from every connected exchange concurrently and rebuild the price pool.""" diff --git a/services/ticker_sources.py b/services/ticker_sources.py index b1194575..bb812acc 100644 --- a/services/ticker_sources.py +++ b/services/ticker_sources.py @@ -38,6 +38,8 @@ Union, ) +from config import settings + logger = logging.getLogger(__name__) # Per-connector fetch timeout (seconds). One slow exchange must not stall the whole cycle. @@ -299,7 +301,9 @@ def _bybit_perpetual_path() -> Dict[str, str]: ), } -# Connector name variants that share another connector's spec/adapter. +# Mainnet connector variants that share another connector's spec/adapter. Testnets are +# deliberately left out: their prices and volumes are not real market data, so they have no +# business in the pool that backs cross-rate pricing. _SPEC_ALIASES: Dict[str, str] = { "kucoin_hft": "kucoin", } @@ -332,6 +336,7 @@ def _normalize( Tuple[Any, Optional[Decimal], Optional[Decimal], Optional[Decimal]], ], connector_name: str, + timestamp: Optional[float] = None, ) -> Dict[str, Ticker]: """ Map raw rows to ``{hb_pair -> Ticker}``. @@ -340,11 +345,14 @@ def _normalize( symbol the connector does not track, or with no usable price, are skipped; each row is guarded so one malformed entry cannot abort the whole exchange. + ``timestamp`` overrides the collection time, for rows served from a slower-refreshing + snapshot; it defaults to now. + Raises TickerFetchError when rows were present but none matched the symbol map, which is what a payload/symbol-map key mismatch looks like. Silently returning {} there would show up as an empty 200 with no clue as to why. """ - now = time.time() + now = time.time() if timestamp is None else timestamp out: Dict[str, Ticker] = {} seen = 0 unmapped = 0 @@ -477,8 +485,78 @@ def extract(row): return _normalize(symbol_map, rows, extract, "hyperliquid") +def _hyperliquid_perp_extract(row): + """Price/volume out of a Hyperliquid perp asset context (main dex or HIP-3).""" + return ( + row.get("_name"), + _mid(None, None, row.get("midPx")) or _to_decimal(row.get("markPx")), + _to_decimal(row.get("dayBaseVlm")), + _to_decimal(row.get("dayNtlVlm")), + ) + + +# HIP-3 snapshots per connector name: {name: (fetched_at, rows)}. Shared exchange-wide data, +# so the connector instance it came from does not matter. +_hip3_snapshots: Dict[str, Tuple[float, List[Dict[str, Any]]]] = {} + + +async def _hyperliquid_hip3_tickers( + connector, symbol_map: Mapping[str, str], connector_name: str +) -> Dict[str, Ticker]: + """ + Tickers for HIP-3 builder-deployed perp dexes (``xyz:TSLA``, ``flx:...``, ...). + + These live on separate dexes and are absent from ``metaAndAssetCtxs``; collecting them + costs one ``allPerpMetas`` call plus one ``metaAndAssetCtxs`` per dex (~10 requests, ~4s). + That is too expensive for every cycle, so the snapshot is refreshed on its own interval + and reused in between -- HIP-3 prices are therefore up to that interval old, which the + returned tickers report honestly via their timestamp. + + Never raises: HIP-3 is supplementary, so any failure leaves the main dex tickers intact. + """ + interval = settings.market_data.hyperliquid_hip3_interval + if interval <= 0: + return {} + if not hasattr(connector, "_fetch_and_cache_hip3_market_data"): + return {} # hummingbot build without HIP-3 support + + now = time.time() + snapshot = _hip3_snapshots.get(connector_name) + if snapshot is None or now - snapshot[0] > interval: + try: + # Loading the symbol map already fetched and cached the dex markets, so reuse + # those on the first pass instead of paying for the same ~10 requests twice. + cached = getattr(connector, "_dex_markets", None) + dex_markets = cached if (snapshot is None and cached) else \ + await connector._fetch_and_cache_hip3_market_data() + rows = [ + {**row, "_name": row.get("name")} + for row in connector._iter_hip3_merged_markets(dex_markets=dex_markets) + if isinstance(row, dict) + ] + snapshot = (now, rows) + _hip3_snapshots[connector_name] = snapshot + except Exception as e: # noqa: BLE001 + if snapshot is None: + logger.warning(f"[{connector_name}] HIP-3 market fetch failed: {e}") + return {} + logger.warning(f"[{connector_name}] HIP-3 refresh failed, serving last snapshot: {e}") + + fetched_at, rows = snapshot + if not rows: + return {} + try: + return _normalize( + symbol_map, rows, _hyperliquid_perp_extract, f"{connector_name}:hip3", + timestamp=fetched_at, + ) + except TickerFetchError as e: + logger.warning(f"[{connector_name}] HIP-3 markets could not be mapped: {e}") + return {} + + async def _hyperliquid_perpetual(connector, symbol_map: Mapping[str, str]) -> Dict[str, Ticker]: - """Hyperliquid perpetuals. + """Hyperliquid perpetuals, main dex plus HIP-3 builder dexes. ``metaAndAssetCtxs`` returns ``[meta, assetCtxs]`` of equal length, index-aligned, and the ctxs carry no symbol of their own -- zipping against ``meta["universe"]`` is the only join. @@ -498,15 +576,9 @@ async def _hyperliquid_perpetual(connector, symbol_map: Mapping[str, str]) -> Di if isinstance(entry, dict) and isinstance(ctx, dict) ] - def extract(row): - return ( - row.get("_name"), - _mid(None, None, row.get("midPx")) or _to_decimal(row.get("markPx")), - _to_decimal(row.get("dayBaseVlm")), - _to_decimal(row.get("dayNtlVlm")), - ) - - return _normalize(symbol_map, rows, extract, "hyperliquid_perpetual") + tickers = _normalize(symbol_map, rows, _hyperliquid_perp_extract, "hyperliquid_perpetual") + tickers.update(await _hyperliquid_hip3_tickers(connector, symbol_map, "hyperliquid_perpetual")) + return tickers TICKER_ADAPTERS: Dict[ From ff7f5191994c7ee71e5635bb039d09c907ea099d Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 29 Jul 2026 09:40:50 +0300 Subject: [PATCH 9/9] (feat) remove hummingbot dependency --- utils/rate_finder.py | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/utils/rate_finder.py b/utils/rate_finder.py index d6e9af51..23c1311c 100644 --- a/utils/rate_finder.py +++ b/utils/rate_finder.py @@ -7,15 +7,48 @@ in order: a direct lookup, the reverse pair (reciprocal), or a bridged cross-rate through any intermediate pair that shares the base or quote asset. -The helper functions (``combine_to_hb_trading_pair``, ``split_hb_trading_pair`` and -``unwrap_token_symbol``) are reused from hummingbot to keep trading-pair formatting and -token normalization identical to the rest of the stack. +The trading-pair helpers (``combine_to_hb_trading_pair`` and ``split_hb_trading_pair``) are +reused from hummingbot. ``unwrap_token_symbol`` is vendored below instead of imported: it has +moved between ``hummingbot.core.gateway.utils`` and ``hummingbot.core.rate_oracle.utils`` +across hummingbot versions, so importing it makes the API fail to boot on some installs. """ +import re from decimal import Decimal -from typing import Dict, Optional +from typing import Dict, List, Match, Optional, Pattern from hummingbot.connector.utils import combine_to_hb_trading_pair, split_hb_trading_pair -from hummingbot.core.rate_oracle.utils import unwrap_token_symbol + +# W{TOKEN} only applies to a few special tokens. It should NOT match all W-prefixed token names like WAVE or WOW. +CAPITAL_W_SYMBOLS_PATTERN = re.compile(r"^W(BTC|ETH|AVAX|ALBT|XRP|POL)") + +# w{TOKEN} generally means a wrapped token on the Ethereum network. e.g. wNXM, wDGLD. +SMALL_W_SYMBOLS_PATTERN = re.compile(r"^w(\w+)") + +# {TOKEN}.e generally means a wrapped token on the Avalanche network. +DOT_E_SYMBOLS_PATTERN = re.compile(r"(\w+)\.e$", re.IGNORECASE) + +USD_EQUIVALANT_TOKENS = ["USC"] + + +def unwrap_token_symbol(on_chain_token_symbol: str) -> str: + """ + Normalize a wrapped/bridged token symbol to its underlying asset (e.g. WBTC -> BTC, + wNXM -> NXM, USDC.e -> USDC). Vendored from hummingbot to keep normalization identical + to the rest of the stack without depending on its module layout. + """ + patterns: List[Pattern] = [ + CAPITAL_W_SYMBOLS_PATTERN, + SMALL_W_SYMBOLS_PATTERN, + DOT_E_SYMBOLS_PATTERN + ] + for p in patterns: + m: Optional[Match] = p.search(on_chain_token_symbol) + if m is not None: + return m.group(1) + + if on_chain_token_symbol in USD_EQUIVALANT_TOKENS: + on_chain_token_symbol = "USDT" + return on_chain_token_symbol def find_rate(prices: Dict[str, Decimal], pair: str) -> Optional[Decimal]: