triangular_arbitrage_bot/oe_em/kucoin_api.py

96 lines
3.5 KiB
Python

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