Files
DinQuant/backend_api_python/app/services/live_trading/kucoin.py
T
TIANHE 5a4c770279 feat: Refactor settings UI and fix commission fee recording
Settings improvements:
- Reorganize config groups with logical ordering (server, auth, ai, trading, etc.)
- Add description/tooltip for each config item with question mark icon
- Add icon to each group header
- Support i18n for descriptions (zh-CN, zh-TW, en-US)
- Move order execution config (ORDER_MODE, MAKER_WAIT_SEC, MAKER_OFFSET_BPS) to env

Commission fee fixes:
- Fix fee extraction in exchange clients: Bybit, Coinbase, Kraken, Gate, Kucoin, Bitfinex
- Properly accumulate and record commission fees in pending_order_worker
- Add fee/fee_ccy fields to wait_for_fill returns

Frontend updates:
- Remove order_mode config from trading-assistant frontend (now uses env config)
- Add sorted schema display by order field
- Add tooltip with description on hover
2026-01-12 00:15:52 +08:00

519 lines
21 KiB
Python

"""
KuCoin (direct REST) client (spot).
Signing (v2):
- KC-API-SIGN = base64(hmac_sha256(secret, timestamp + method + requestPathWithQuery + body))
- KC-API-PASSPHRASE = base64(hmac_sha256(secret, passphrase))
- KC-API-KEY-VERSION: 2
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import time
from decimal import Decimal, ROUND_DOWN
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
from app.services.live_trading.symbols import to_kucoin_symbol
class KucoinSpotClient(BaseRestClient):
def __init__(
self,
*,
api_key: str,
secret_key: str,
passphrase: str,
base_url: str = "https://api.kucoin.com",
timeout_sec: float = 15.0,
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.passphrase = (passphrase or "").strip()
if not self.api_key or not self.secret_key or not self.passphrase:
raise LiveTradingError("Missing KuCoin api_key/secret_key/passphrase")
def _b64_hmac_sha256(self, key: str, msg: str) -> str:
mac = hmac.new(key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).digest()
return base64.b64encode(mac).decode("utf-8")
def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]:
# passphrase must be signed (v2)
p = self._b64_hmac_sha256(self.secret_key, self.passphrase)
return {
"KC-API-KEY": self.api_key,
"KC-API-SIGN": sign,
"KC-API-TIMESTAMP": ts_ms,
"KC-API-PASSPHRASE": p,
"KC-API-KEY-VERSION": "2",
"Content-Type": "application/json",
}
def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
m = str(method or "GET").upper()
ts_ms = str(int(time.time() * 1000))
body_str = self._json_dumps(json_body) if json_body is not None else ""
qs = ""
if params:
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
qs = urlencode(sorted(norm.items()), doseq=True)
signed_path = f"{path}?{qs}" if qs else path
prehash = f"{ts_ms}{m}{signed_path}{body_str}"
sign = self._b64_hmac_sha256(self.secret_key, prehash)
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts_ms, sign))
if code >= 400:
raise LiveTradingError(f"KuCoin HTTP {code}: {text[:500]}")
return data
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any:
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
if code >= 400:
raise LiveTradingError(f"KuCoin HTTP {code}: {text[:500]}")
return data
def ping(self) -> bool:
try:
d = self._public_request("GET", "/api/v1/timestamp")
return isinstance(d, dict) and str(d.get("code") or "") in ("200000", "0", "")
except Exception:
return False
def get_accounts(self) -> Any:
return self._signed_request("GET", "/api/v1/accounts")
def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
qty = float(size or 0.0)
px = float(price or 0.0)
if qty <= 0 or px <= 0:
raise LiveTradingError("Invalid size/price")
body: Dict[str, Any] = {
"clientOid": str(client_order_id or str(int(time.time() * 1000))),
"side": sd,
"symbol": to_kucoin_symbol(symbol),
"type": "limit",
"price": str(px),
"size": str(qty),
"timeInForce": "GTC",
}
raw = self._signed_request("POST", "/api/v1/orders", json_body=body)
oid = ""
if isinstance(raw, dict):
d = raw.get("data")
if isinstance(d, dict):
oid = str(d.get("orderId") or "")
elif isinstance(d, str):
oid = str(d)
return LiveOrderResult(exchange_id="kucoin", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
def place_market_order(
self,
*,
symbol: str,
side: str,
size: float,
client_order_id: Optional[str] = None,
quote_size: bool = False,
) -> LiveOrderResult:
"""
KuCoin market order:
- sell: use size (base quantity)
- buy: typically use funds (quote quantity). Set quote_size=True to treat `size` as funds.
"""
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
qty = float(size or 0.0)
if qty <= 0:
raise LiveTradingError("Invalid size")
body: Dict[str, Any] = {
"clientOid": str(client_order_id or str(int(time.time() * 1000))),
"side": sd,
"symbol": to_kucoin_symbol(symbol),
"type": "market",
}
if sd == "buy" and quote_size:
body["funds"] = str(qty)
else:
body["size"] = str(qty)
raw = self._signed_request("POST", "/api/v1/orders", json_body=body)
oid = ""
if isinstance(raw, dict):
d = raw.get("data")
if isinstance(d, dict):
oid = str(d.get("orderId") or "")
elif isinstance(d, str):
oid = str(d)
return LiveOrderResult(exchange_id="kucoin", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Any:
if order_id:
return self._signed_request("DELETE", f"/api/v1/orders/{str(order_id)}")
if client_order_id:
return self._signed_request("DELETE", f"/api/v1/order/client-order/{str(client_order_id)}")
raise LiveTradingError("KuCoin cancel_order requires order_id or client_order_id")
def get_order(self, *, order_id: str = "", client_order_id: str = "") -> Any:
if order_id:
return self._signed_request("GET", f"/api/v1/orders/{str(order_id)}")
if client_order_id:
return self._signed_request("GET", f"/api/v1/order/client-order/{str(client_order_id)}")
raise LiveTradingError("KuCoin get_order requires order_id or client_order_id")
def get_fills(self, *, order_id: str) -> Any:
return self._signed_request("GET", "/api/v1/fills", params={"orderId": str(order_id)})
def wait_for_fill(self, *, order_id: str, max_wait_sec: float = 10.0, poll_interval_sec: float = 0.5) -> Dict[str, Any]:
end_ts = time.time() + float(max_wait_sec or 0.0)
last: Dict[str, Any] = {}
while True:
try:
resp = self.get_order(order_id=str(order_id))
last = resp if isinstance(resp, dict) else {"raw": resp}
except Exception:
last = last or {}
data = last.get("data") if isinstance(last, dict) else None
od = data if isinstance(data, dict) else {}
status = str(od.get("isActive") if od else "")
filled = 0.0
avg_price = 0.0
fee = 0.0
fee_ccy = ""
try:
filled = float(od.get("dealSize") or 0.0)
except Exception:
filled = 0.0
try:
funds = float(od.get("dealFunds") or 0.0)
if filled > 0 and funds > 0:
avg_price = funds / filled
except Exception:
avg_price = 0.0
try:
fee = abs(float(od.get("fee") or 0.0))
except Exception:
fee = 0.0
fee_ccy = str(od.get("feeCurrency") or "").strip()
if filled > 0 and avg_price > 0:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
# If order is inactive, consider it terminal
try:
is_active = bool(od.get("isActive"))
except Exception:
is_active = False
if not is_active:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
if time.time() >= end_ts:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
time.sleep(float(poll_interval_sec or 0.5))
class KucoinFuturesClient(BaseRestClient):
"""
KuCoin Futures (USDT perpetual) direct REST client.
Notes:
- Base URL typically: https://api-futures.kucoin.com
- Auth headers/signing are the same KC-API-* style as spot (v2 passphrase signing),
but endpoints and symbol formats differ.
- Futures order size is typically in contracts; we convert from "base qty" best-effort.
"""
def __init__(
self,
*,
api_key: str,
secret_key: str,
passphrase: str,
base_url: str = "https://api-futures.kucoin.com",
timeout_sec: float = 15.0,
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.passphrase = (passphrase or "").strip()
if not self.api_key or not self.secret_key or not self.passphrase:
raise LiveTradingError("Missing KuCoin Futures api_key/secret_key/passphrase")
# Best-effort contract cache: symbol -> (ts, contract_dict)
self._contract_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
self._contract_cache_ttl_sec = 300.0
def _b64_hmac_sha256(self, key: str, msg: str) -> str:
mac = hmac.new(key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).digest()
return base64.b64encode(mac).decode("utf-8")
def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]:
p = self._b64_hmac_sha256(self.secret_key, self.passphrase)
return {
"KC-API-KEY": self.api_key,
"KC-API-SIGN": sign,
"KC-API-TIMESTAMP": ts_ms,
"KC-API-PASSPHRASE": p,
"KC-API-KEY-VERSION": "2",
"Content-Type": "application/json",
}
def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
m = str(method or "GET").upper()
ts_ms = str(int(time.time() * 1000))
body_str = self._json_dumps(json_body) if json_body is not None else ""
qs = ""
if params:
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
qs = urlencode(sorted(norm.items()), doseq=True)
signed_path = f"{path}?{qs}" if qs else path
prehash = f"{ts_ms}{m}{signed_path}{body_str}"
sign = self._b64_hmac_sha256(self.secret_key, prehash)
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts_ms, sign))
if code >= 400:
raise LiveTradingError(f"KuCoinFutures HTTP {code}: {text[:500]}")
return data
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any:
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
if code >= 400:
raise LiveTradingError(f"KuCoinFutures HTTP {code}: {text[:500]}")
return data
def ping(self) -> bool:
try:
d = self._public_request("GET", "/api/v1/timestamp")
return isinstance(d, dict) and str(d.get("code") or "") in ("200000", "0", "")
except Exception:
return False
def get_contract(self, *, symbol: str) -> Dict[str, Any]:
from app.services.live_trading.symbols import to_kucoin_futures_symbol
sym = to_kucoin_futures_symbol(symbol)
if not sym:
return {}
now = time.time()
cached = self._contract_cache.get(sym)
if cached:
ts, obj = cached
if obj and (now - float(ts or 0.0)) <= float(self._contract_cache_ttl_sec or 300.0):
return obj
# KuCoin futures active contracts list
raw = self._public_request("GET", "/api/v1/contracts/active")
data = (raw.get("data") if isinstance(raw, dict) else None) or []
found: Dict[str, Any] = {}
if isinstance(data, list):
for it in data:
if not isinstance(it, dict):
continue
if str(it.get("symbol") or "").upper() == sym.upper():
found = it
break
if found:
self._contract_cache[sym] = (now, found)
return found
def _base_to_contracts(self, *, symbol: str, base_size: float) -> int:
"""
Convert base-asset qty -> contracts best-effort using multiplier.
"""
from app.services.live_trading.symbols import to_kucoin_futures_symbol
req = Decimal(str(base_size or 0.0))
if req <= 0:
return 0
sym = to_kucoin_futures_symbol(symbol)
meta: Dict[str, Any] = {}
try:
meta = self.get_contract(symbol=sym) or {}
except Exception:
meta = {}
# multiplier is base per contract for many KuCoin perps (best-effort)
mult = Decimal(str(meta.get("multiplier") or meta.get("lotSize") or "0"))
if mult <= 0:
mult = Decimal("1")
ct = (req / mult).to_integral_value(rounding=ROUND_DOWN)
try:
return int(ct)
except Exception:
return 0
def get_accounts(self) -> Any:
# Futures account overview
return self._signed_request("GET", "/api/v1/account-overview", params={"currency": "USDT"})
def get_positions(self) -> Any:
return self._signed_request("GET", "/api/v1/positions")
def set_leverage(self, *, symbol: str, leverage: float) -> bool:
from app.services.live_trading.symbols import to_kucoin_futures_symbol
sym = to_kucoin_futures_symbol(symbol)
try:
lv = int(float(leverage or 1.0))
except Exception:
lv = 1
if lv < 1:
lv = 1
body = {"symbol": sym, "leverage": str(lv)}
try:
_ = self._signed_request("POST", "/api/v1/position/leverage", json_body=body)
return True
except Exception:
return False
def place_market_order(
self,
*,
symbol: str,
side: str,
size: float,
reduce_only: bool = False,
client_order_id: Optional[str] = None,
) -> LiveOrderResult:
from app.services.live_trading.symbols import to_kucoin_futures_symbol
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
sym = to_kucoin_futures_symbol(symbol)
qty_ct = self._base_to_contracts(symbol=sym, base_size=float(size or 0.0))
if qty_ct <= 0:
raise LiveTradingError("Invalid size (converted contracts <= 0)")
body: Dict[str, Any] = {
"clientOid": str(client_order_id or str(int(time.time() * 1000))),
"side": sd,
"symbol": sym,
"type": "market",
"size": qty_ct,
}
if reduce_only:
body["reduceOnly"] = True
raw = self._signed_request("POST", "/api/v1/orders", json_body=body)
oid = ""
if isinstance(raw, dict):
d = raw.get("data")
if isinstance(d, dict):
oid = str(d.get("orderId") or "")
elif isinstance(d, str):
oid = str(d)
return LiveOrderResult(exchange_id="kucoin", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
def place_limit_order(
self,
*,
symbol: str,
side: str,
size: float,
price: float,
reduce_only: bool = False,
post_only: bool = False,
client_order_id: Optional[str] = None,
) -> LiveOrderResult:
from app.services.live_trading.symbols import to_kucoin_futures_symbol
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
sym = to_kucoin_futures_symbol(symbol)
px = float(price or 0.0)
if px <= 0:
raise LiveTradingError("Invalid price")
qty_ct = self._base_to_contracts(symbol=sym, base_size=float(size or 0.0))
if qty_ct <= 0:
raise LiveTradingError("Invalid size (converted contracts <= 0)")
body: Dict[str, Any] = {
"clientOid": str(client_order_id or str(int(time.time() * 1000))),
"side": sd,
"symbol": sym,
"type": "limit",
"price": str(px),
"size": qty_ct,
}
if reduce_only:
body["reduceOnly"] = True
if post_only:
body["postOnly"] = True
raw = self._signed_request("POST", "/api/v1/orders", json_body=body)
oid = ""
if isinstance(raw, dict):
d = raw.get("data")
if isinstance(d, dict):
oid = str(d.get("orderId") or "")
elif isinstance(d, str):
oid = str(d)
return LiveOrderResult(exchange_id="kucoin", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Any:
if order_id:
return self._signed_request("DELETE", f"/api/v1/orders/{str(order_id)}")
if client_order_id:
return self._signed_request("DELETE", f"/api/v1/orders/client-order/{str(client_order_id)}")
raise LiveTradingError("KuCoinFutures cancel_order requires order_id or client_order_id")
def get_order(self, *, order_id: str = "", client_order_id: str = "") -> Any:
if order_id:
return self._signed_request("GET", f"/api/v1/orders/{str(order_id)}")
if client_order_id:
return self._signed_request("GET", f"/api/v1/orders/byClientOid", params={"clientOid": str(client_order_id)})
raise LiveTradingError("KuCoinFutures get_order requires order_id or client_order_id")
def wait_for_fill(self, *, order_id: str, max_wait_sec: float = 3.0, poll_interval_sec: float = 0.5) -> Dict[str, Any]:
end_ts = time.time() + float(max_wait_sec or 0.0)
last: Dict[str, Any] = {}
while True:
try:
resp = self.get_order(order_id=str(order_id))
last = resp if isinstance(resp, dict) else {"raw": resp}
except Exception:
last = last or {}
od = (last.get("data") if isinstance(last, dict) else None) or {}
status = str(od.get("status") or "")
filled = 0.0
avg_price = 0.0
fee = 0.0
fee_ccy = ""
try:
# dealSize is in contracts; convert back to base using multiplier best-effort.
deal_ct = float(od.get("dealSize") or 0.0)
except Exception:
deal_ct = 0.0
try:
deal_value = float(od.get("dealValue") or 0.0)
except Exception:
deal_value = 0.0
# Best-effort: infer avg price from dealValue / (deal contracts * multiplier)
mult = 1.0
try:
sym = str(od.get("symbol") or "")
meta = self.get_contract(symbol=sym) or {}
mult = float(meta.get("multiplier") or meta.get("lotSize") or 1.0)
if mult <= 0:
mult = 1.0
except Exception:
mult = 1.0
filled = abs(float(deal_ct or 0.0)) * float(mult)
if filled > 0 and deal_value > 0:
avg_price = float(deal_value) / float(filled)
# Extract fee from KuCoin Futures API (orderMargin contains fee info in some cases)
try:
fee = abs(float(od.get("fee") or od.get("orderFee") or 0.0))
except Exception:
fee = 0.0
# KuCoin Futures fees are typically in USDT
if fee > 0:
fee_ccy = "USDT"
if filled > 0 and avg_price > 0:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
if status.lower() in ("done", "canceled", "cancelled", "filled"):
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
if time.time() >= end_ts:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
time.sleep(float(poll_interval_sec or 0.5))