Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ 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"
)
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")

Expand Down
2 changes: 2 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 2 additions & 4 deletions models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,8 @@
ActiveFeedInfo,
ActiveFeedsResponse,
AddTradingPairRequest,
AllTickersResponse,
CandleData,
CandlesResponse,
ConnectorTickersResponse,
FundingInfoRequest,
FundingInfoResponse,
MarketDataSettings,
Expand All @@ -143,6 +141,7 @@
SingleRateResponse,
SupportedOrderTypesResponse,
TickerInfo,
TickersResponse,
TradingPairResponse,
TradingRulesResponse,
VolumeForPriceRequest,
Expand Down Expand Up @@ -266,8 +265,7 @@
"TradingPairResponse",
# Ticker & rate models
"TickerInfo",
"ConnectorTickersResponse",
"AllTickersResponse",
"TickersResponse",
"RateRequest",
"RatesResponse",
"SingleRateResponse",
Expand Down
29 changes: 19 additions & 10 deletions models/market_data.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional

from pydantic import BaseModel, Field
Expand Down Expand Up @@ -198,22 +197,32 @@ 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")


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")


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):
Expand Down
96 changes: 78 additions & 18 deletions routers/market_data.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import asyncio
import logging
import time
from typing import List, 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

from config import settings
from deps import get_market_data_service
from models import (
AddTradingPairRequest,
AllTickersResponse,
ConnectorTickersResponse,
FundingInfoRequest,
FundingInfoResponse,
OrderBookLevel,
Expand All @@ -29,12 +28,15 @@
RemoveTradingPairRequest,
SingleRateResponse,
TickerInfo,
TickersResponse,
TradingPairResponse,
VolumeForPriceRequest,
VWAPForVolumeRequest,
)
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__)

Expand Down Expand Up @@ -283,26 +285,84 @@ 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"
),
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, {})
info = _tickers_to_info(tickers)
return ConnectorTickersResponse(connector=connector_name, count=len(info), tickers=info)
"""
Get tickers grouped by connector, with 24h base and quote volume where available.

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, errors = await market_data_manager.fetch_tickers_for(
targets, max_age=max_age, force=refresh
)
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)
Expand Down
Loading
Loading