triangular_arbitrage_bot/executor/kucoin_api.py

458 lines
16 KiB
Python

"""
KuCoin REST API client for the executor.
Covers symbol metadata fetch, HF order-test endpoint for paper mode,
real order placement for live mode, and private WebSocket token
acquisition required by ws_client.py.
"""
import aiohttp
import base64
import hmac
import hashlib
import json
import time
import uuid
import structlog
from decimal import Decimal
from typing import Optional
logger = structlog.get_logger().bind(component="executor-kucoin")
KUCoin_SYMBOLS_URL = "https://api.kucoin.com/api/v1/symbols"
KUCoin_ORDER_TEST_URL = "https://api.kucoin.com/api/v1/hf/orders/test"
KUCoin_ORDER_PLACE_URL = "https://api.kucoin.com/api/v1/hf/orders"
KUCoin_SERVER_TIME_URL = "https://api.kucoin.com/api/v1/time"
KUCoin_BULLET_PRIVATE_URL = "https://api.kucoin.com/api/v1/bullet-private"
def _signRequest(timestamp: str, method: str, path: str, secret: str, data: str = "") -> str:
"""
Compute the HMAC-SHA256 signature for a KuCoin API request.
Parameters
----------
timestamp : str
Millisecond Unix timestamp.
method : str
HTTP method (e.g. "POST").
path : str
API endpoint path (e.g. "/api/v1/hf/orders/test").
secret : str
API secret as returned by the user.
data : str
Request body. Empty string for GET requests.
Returns
-------
str
Base64-encoded HMAC-SHA256 digest suitable for the KC-API-SIGN header.
"""
# KuCoin signs timestamp + method + path + body concatenated.
message = timestamp + method + path + data
mac = hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256)
return base64.b64encode(mac.digest()).decode("utf-8")
def _encryptPassphrase(passphrase: str, secret: str) -> str:
"""
Encrypt the API passphrase for KuCoin.
KuCoin requires the passphrase to be encrypted with HMAC-SHA256
using the API secret as the key, then base64-encoded.
Returns
-------
str
Base64-encoded encrypted passphrase for the KC-API-PASSPHRASE header.
"""
mac = hmac.new(secret.encode("utf-8"), passphrase.encode("utf-8"), hashlib.sha256)
return base64.b64encode(mac.digest()).decode("utf-8")
class SymbolMeta:
"""
Cached metadata for a single KuCoin trading pair.
Fetched once at startup from /api/v1/symbols and held in memory
for the lifetime of the process.
"""
def __init__(
self,
symbol: str,
base: str,
quote: str,
base_min_size: Decimal,
quote_min_size: Decimal,
base_increment: Decimal,
quote_increment: Decimal,
min_funds: Decimal,
taker_fee: Decimal,
maker_fee: Decimal,
incr_min_size: Decimal,
base_precision: int,
quote_precision: int,
) -> None:
self.symbol = symbol
self.base = base
self.quote = quote
self.base_min_size = base_min_size
self.quote_min_size = quote_min_size
self.base_increment = base_increment
self.quote_increment = quote_increment
self.min_funds = min_funds
self.taker_fee = taker_fee
self.maker_fee = maker_fee
self.incr_min_size = incr_min_size
self.base_precision = base_precision
self.quote_precision = quote_precision
def _cost_to_precision(amount: Decimal, quote_increment: Decimal) -> Decimal:
"""
Round a quote-currency amount *up* to the nearest valid increment.
KuCoin requires quote amounts to be multiples of quote_increment. When
computing the minimum order size in quote currency we therefore round up
so we never underestimate the minimum.
"""
if quote_increment <= 0:
return amount
mult = Decimal("1") / quote_increment
return (amount * mult).to_integral_value(rounding="ROUND_UP") / mult
def _amount_to_precision(
amount: Decimal,
base_increment: Decimal,
round_down: bool = False,
) -> Decimal:
"""
Round a base-currency amount to the nearest valid increment.
Use round_down=True for sell orders so the resulting size never
exceeds the available balance. Use round_down=False (default) for
buy orders and minimum-size computations where rounding up is safe.
"""
if base_increment <= 0:
return amount
mult = Decimal("1") / base_increment
rounding = "ROUND_DOWN" if round_down else "ROUND_UP"
return (amount * mult).to_integral_value(rounding=rounding) / mult
class KuCoinAPI:
"""
Thin client for KuCoin REST API calls needed by the executor.
Handles HMAC signing, symbol metadata caching, order placement
(live) and order validation (paper), plus private token fetch
for the WebSocket client.
"""
def __init__(self, api_key: str = "", api_secret: str = "", api_passphrase: str = "") -> None:
self._api_key = api_key
self._api_secret = api_secret
self._api_passphrase = api_passphrase
# Pre-encrypt the passphrase at init time so we don't recompute per request.
self._encrypted_passphrase = _encryptPassphrase(api_passphrase, api_secret) if api_passphrase and api_secret else ""
self._symbols: dict[str, SymbolMeta] = {}
self._log = logger
async def fetch_symbols(self) -> None:
"""
Fetch all trading pair metadata from KuCoin and populate _symbols.
Logs a warning for any symbol that fails to parse and skips it.
"""
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", "")
if not all([symbol, base, quote]):
continue
raw_base_min = item.get("baseMinSize") or "0"
raw_quote_min = item.get("quoteMinSize") or "0"
raw_base_inc = item.get("baseIncrement") or "0"
raw_quote_inc = item.get("quoteIncrement") or "0"
raw_min_funds = item.get("minFunds") or "0"
raw_maker = item.get("makerFeeRate") or "0"
raw_taker = item.get("takerFeeRate") or "0"
raw_incr_min = item.get("incrMinSize") or "0"
raw_base_prec = item.get("basePrecision") or "0"
raw_quote_prec = item.get("quotePrecision") or "0"
try:
base_min_size = Decimal(raw_base_min)
quote_min_size = Decimal(raw_quote_min)
base_increment = Decimal(raw_base_inc)
quote_increment = Decimal(raw_quote_inc)
min_funds = Decimal(raw_min_funds)
taker_fee = Decimal(raw_taker)
maker_fee = Decimal(raw_maker)
incr_min_size = Decimal(raw_incr_min)
base_precision = int(raw_base_prec) if raw_base_prec else 0
quote_precision = int(raw_quote_prec) if raw_quote_prec else 0
except Exception as e:
self._log.warning("symbol_field_parse_error", symbol=symbol, error=str(e),
baseMinSize=repr(raw_base_min), quoteMinSize=repr(raw_quote_min),
baseIncrement=repr(raw_base_inc), quoteIncrement=repr(raw_quote_inc),
minFunds=repr(raw_min_funds), makerFeeRate=repr(raw_maker),
takerFeeRate=repr(raw_taker), incrMinSize=repr(raw_incr_min),
basePrecision=repr(raw_base_prec), quotePrecision=repr(raw_quote_prec))
continue
self._symbols[symbol] = SymbolMeta(
symbol=symbol,
base=base,
quote=quote,
base_min_size=base_min_size,
quote_min_size=quote_min_size,
base_increment=base_increment,
quote_increment=quote_increment,
min_funds=min_funds,
taker_fee=taker_fee,
maker_fee=maker_fee,
incr_min_size=incr_min_size,
base_precision=base_precision,
quote_precision=quote_precision,
)
self._log.info("symbols_loaded", count=len(self._symbols))
def get_symbol_meta(self, symbol: str) -> Optional[SymbolMeta]:
"""Return cached SymbolMeta for a pair, or None if not yet loaded."""
return self._symbols.get(symbol)
async def order_test(
self,
session: aiohttp.ClientSession,
symbol: str,
side: str,
order_type: str,
price: Optional[Decimal] = None,
size: Optional[Decimal] = None,
funds: Optional[Decimal] = None,
) -> tuple[bool, str, Optional[str]]:
"""
Validate an order against KuCoin's paper-trading endpoint.
In paper mode the executor uses this instead of a real order so that
fill simulation can proceed without risking capital.
Parameters
----------
session : aiohttp.ClientSession
Shared session for connection pooling.
symbol : str
KuCoin symbol e.g. "BTC-USDT".
side : str
"buy" or "sell".
order_type : str
Order type string (e.g. "market").
price, size, funds : Optional[Decimal]
Order parameters; at least one must be provided.
Returns
-------
tuple[bool, str, Optional[str]]
(success, error_message, order_id). order_id is populated on success.
"""
timestamp = str(int(time.time() * 1000))
path = "/api/v1/hf/orders/test"
body: dict = {
"symbol": symbol,
"type": order_type,
"side": side,
"clientOid": f"paper-{timestamp}-{uuid.uuid4().hex[:8]}",
}
if price is not None:
body["price"] = str(price)
if size is not None:
body["size"] = str(size)
if funds is not None:
body["funds"] = str(funds)
raw_body = json.dumps(body, separators=(",", ":"))
sign = _signRequest(timestamp, "POST", path, self._api_secret, raw_body)
headers = {
"KC-API-TIMESTAMP": timestamp,
"KC-API-SIGN": sign,
"KC-API-KEY": self._api_key,
"KC-API-PASSPHRASE": self._encrypted_passphrase,
"KC-API-SIGN-TYPE": "2",
"KC-API-KEY-VERSION": "3",
"Content-Type": "application/json",
}
try:
async with session.post(
KUCoin_ORDER_TEST_URL,
data=raw_body.encode(),
headers=headers,
) as resp:
text = await resp.text()
if resp.status == 200:
data = json.loads(text)
order_id = data.get("data", {}).get("orderId", "")
return True, "", order_id
else:
data = json.loads(text)
code = data.get("code", "")
msg = data.get("msg", text)
return False, f"{code}: {msg}", None
except (json.JSONDecodeError, ValueError) as e:
return False, f"invalid_response: {e}", None
except (aiohttp.ClientError, OSError) as e:
return False, f"http_error: {e}", None
async def order_place(
self,
session: aiohttp.ClientSession,
symbol: str,
side: str,
order_type: str,
price: Optional[Decimal] = None,
size: Optional[Decimal] = None,
funds: Optional[Decimal] = None,
client_oid: str = "",
) -> tuple[bool, str, Optional[str]]:
"""
Place a real market order on KuCoin.
Parameters
----------
session : aiohttp.ClientSession
Shared session for connection pooling.
symbol : str
KuCoin symbol e.g. "BTC-USDT".
side : str
"buy" or "sell".
order_type : str
Order type string (e.g. "market").
price, size, funds : Optional[Decimal]
Order parameters; at least one must be provided.
client_oid : str
Client-order ID for matching with WS fill events.
Returns
-------
tuple[bool, str, Optional[str]]
(success, error_message, order_id). order_id is populated on success.
"""
timestamp = str(int(time.time() * 1000))
path = "/api/v1/hf/orders"
body: dict = {
"symbol": symbol,
"type": order_type,
"side": side,
"clientOid": client_oid or f"live-{timestamp}-{uuid.uuid4().hex[:8]}",
}
if price is not None:
body["price"] = str(price)
if size is not None:
body["size"] = str(size)
if funds is not None:
body["funds"] = str(funds)
raw_body = json.dumps(body, separators=(",", ":"))
sign = _signRequest(timestamp, "POST", path, self._api_secret, raw_body)
headers = {
"KC-API-TIMESTAMP": timestamp,
"KC-API-SIGN": sign,
"KC-API-KEY": self._api_key,
"KC-API-PASSPHRASE": self._encrypted_passphrase,
"KC-API-SIGN-TYPE": "2",
"KC-API-KEY-VERSION": "3",
"Content-Type": "application/json",
}
try:
async with session.post(
KUCoin_ORDER_PLACE_URL,
data=raw_body.encode(),
headers=headers,
) as resp:
text = await resp.text()
self._log.info("order_place_raw_response", status=resp.status, response=text[:300])
data = json.loads(text)
if resp.status == 200 and data.get("code") == "200000":
order_id = data.get("data", {}).get("orderId", "")
return True, "", order_id
else:
code = data.get("code", resp.status)
msg = data.get("msg", text)
return False, f"{code}: {msg}", None
except (json.JSONDecodeError, ValueError) as e:
return False, f"invalid_response: {e}", None
except (aiohttp.ClientError, OSError) as e:
return False, f"http_error: {e}", None
async def get_private_token(self, session: aiohttp.ClientSession) -> Optional[dict]:
"""
Request a private WebSocket token from KuCoin.
Returns the full data dict from the bullet-private response, containing
'token', 'instanceServers' with endpoint, pingInterval, pingTimeout.
Returns None on error.
"""
timestamp = str(int(time.time() * 1000))
path = "/api/v1/bullet-private"
sign = _signRequest(timestamp, "POST", path, self._api_secret, "")
headers = {
"KC-API-TIMESTAMP": timestamp,
"KC-API-SIGN": sign,
"KC-API-KEY": self._api_key,
"KC-API-PASSPHRASE": self._encrypted_passphrase,
"KC-API-SIGN-TYPE": "2",
"KC-API-KEY-VERSION": "3",
"Content-Type": "application/json",
}
try:
async with session.post(
KUCoin_BULLET_PRIVATE_URL,
data=b"",
headers=headers,
) as resp:
if resp.status == 200:
data = await resp.json()
return data.get("data")
else:
text = await resp.text()
self._log.error("private_token_failed", status=resp.status, response=text[:200])
return None
except (aiohttp.ClientError, OSError) as e:
self._log.error("private_token_http_error", error=str(e))
return None
async def warmup_session(self, session: aiohttp.ClientSession) -> None:
"""Warm up the authenticated HTTP connection pool with a minimal GET."""
timestamp = str(int(time.time() * 1000))
path = "/api/v1/accounts"
sign = _signRequest(timestamp, "GET", path, self._api_secret, "")
headers = {
"KC-API-TIMESTAMP": timestamp,
"KC-API-SIGN": sign,
"KC-API-KEY": self._api_key,
"KC-API-PASSPHRASE": self._encrypted_passphrase,
"KC-API-SIGN-TYPE": "2",
"KC-API-KEY-VERSION": "3",
}
try:
async with session.get(
f"https://api.kucoin.com{path}",
headers=headers,
) as resp:
await resp.read()
pass
except Exception as e:
self._log.warning("session_warmup_failed", error=str(e))