""" KuCoin API client for the opportunity engine. Fetches trading pair metadata (symbol, base, quote, fees, feeCurrency) and builds an in-memory fee table keyed by base currency. This data is used to construct the triangle index and to populate fee_currency in signal payloads. """ import aiohttp import structlog logger = structlog.get_logger().bind(component="kucoin_api") KUCoin_SYMBOLs_URL = "https://api.kucoin.com/api/v1/symbols" DEFAULT_FEES = { "BTC": {"maker": 0.0010, "taker": 0.0010}, "ETH": {"maker": 0.0010, "taker": 0.0010}, "USDT": {"maker": 0.0010, "taker": 0.0010}, "USDC": {"maker": 0.0010, "taker": 0.0010}, } class KuCoinAPI: """ Fetch and cache KuCoin pair metadata and per-currency fee rates. Used at startup to build the fee table required by triangle enumeration. """ def __init__(self) -> None: self._fee_table: dict[str, dict[str, float]] = {} self._pairs: dict[str, dict] = {} self._log = logger async def fetch_pairs_and_fees(self) -> None: """ Fetch all symbols from KuCoin, populate _pairs and _fee_table. Logs warnings for any symbol that cannot be parsed and skips it. Sets feeCurrency to the empty string when absent in the API response. """ async with aiohttp.ClientSession() as session: async with session.get(KUCoin_SYMBOLs_URL) as resp: resp.raise_for_status() payload = await resp.json() for item in payload.get("data", []): symbol = item.get("symbol", "") base = item.get("baseCurrency", "") quote = item.get("quoteCurrency", "") maker_fee = float(item.get("makerFeeRate", 0)) taker_fee = float(item.get("takerFeeRate", 0)) enable_trading = item.get("enableTrading", False) if not all([symbol, base, quote]): continue fee_currency = item.get("feeCurrency") or "" self._pairs[symbol] = { "symbol": symbol, "base": base, "quote": quote, "maker_fee": maker_fee, "taker_fee": taker_fee, "enable_trading": enable_trading, "fee_currency": fee_currency, } if base not in self._fee_table: self._fee_table[base] = { "maker": maker_fee if maker_fee > 0 else DEFAULT_FEES.get(base, {}).get("maker", 0.0010), "taker": taker_fee if taker_fee > 0 else DEFAULT_FEES.get(base, {}).get("taker", 0.0010), } self._log.info("fee_table_loaded", bases=len(self._fee_table), pairs=len(self._pairs)) def get_fee(self, symbol: str, side: str) -> float: """ Return the taker fee rate for the base currency of a given symbol. Falls back to DEFAULT_FEES when the base is not in the fee table. """ if symbol not in self._pairs: return 0.0010 base = self._pairs[symbol]["base"] fee_data = self._fee_table.get(base, DEFAULT_FEES.get(base, {"maker": 0.0010, "taker": 0.0010})) return fee_data.get(side, 0.0010) def get_pair_info(self, symbol: str) -> dict | None: """Return the full info dict for a symbol, or None if not loaded.""" return self._pairs.get(symbol) def get_all_pairs(self) -> list[dict]: """Return all pairs where enable_trading is True.""" return [p for p in self._pairs.values() if p["enable_trading"]]