Files
DinQuant/backend_api_python/app/services/live_trading/htx.py
T
Dinger 8563e4ea53 v3.0.1
Signed-off-by: Dinger <quantdinger@gmail.com>
2026-04-07 22:47:07 +08:00

611 lines
27 KiB
Python

"""
HTX (Huobi) direct REST client for spot and USDT-margined perpetual swap.
References:
- Spot base URL: https://api.huobi.pro
- USDT swap base URL: https://api.hbdm.com
- Spot auth: query params with HmacSHA256 signature
- Swap auth: query params with HmacSHA256 signature, request body in JSON
"""
from __future__ import annotations
import base64
import hashlib
import hmac
from decimal import Decimal, ROUND_DOWN
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode, urlparse
import datetime
import time
import logging
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
from app.services.live_trading.symbols import to_htx_contract_code, to_htx_spot_symbol
logger = logging.getLogger(__name__)
class HtxClient(BaseRestClient):
def __init__(
self,
*,
api_key: str,
secret_key: str,
base_url: str = "https://api.huobi.pro",
futures_base_url: str = "https://api.hbdm.com",
timeout_sec: float = 15.0,
market_type: str = "swap",
broker_id: str = "",
):
chosen_base = futures_base_url if str(market_type or "").strip().lower() == "swap" else base_url
super().__init__(base_url=chosen_base, timeout_sec=timeout_sec)
self.spot_base_url = (base_url or "https://api.huobi.pro").rstrip("/")
self.futures_base_url = (futures_base_url or "https://api.hbdm.com").rstrip("/")
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.market_type = (market_type or "swap").strip().lower()
self.broker_id = (broker_id or "").strip()
if self.market_type not in ("spot", "swap"):
self.market_type = "swap"
if not self.api_key or not self.secret_key:
raise LiveTradingError("Missing HTX api_key/secret_key")
self._spot_account_id: Optional[str] = None
self._contract_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
self._contract_cache_ttl_sec = 300.0
self._lever_cache: Dict[str, int] = {}
@staticmethod
def _format_swap_client_order_id(client_order_id: Optional[str]) -> Optional[int]:
"""HTX swap/linear-swap client_order_id must be a pure numeric long (1~9223372036854775807)."""
if not client_order_id:
return None
digits = "".join(c for c in str(client_order_id) if c.isdigit())
if not digits:
digits = str(int(time.time() * 1000))
val = int(digits[-18:])
return val if 0 < val <= 9223372036854775807 else None
def _format_spot_client_order_id(self, client_order_id: Optional[str]) -> str:
prefix = str(self.broker_id or "").strip()
raw = str(client_order_id or "").strip()
if not prefix and not raw:
return ""
if not raw:
raw = str(int(time.time() * 1000))
allowed = []
for ch in raw:
if ch.isalnum() or ch in ("_", "-"):
allowed.append(ch)
suffix = "".join(allowed).strip("-_")
if not suffix:
suffix = str(int(time.time() * 1000))
if prefix:
if suffix.startswith(prefix):
combined = suffix
else:
combined = f"{prefix}-{suffix}"
else:
combined = suffix
return combined[:64]
@staticmethod
def _utc_ts() -> str:
return datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
return Decimal(str(x))
except Exception:
return Decimal("0")
@staticmethod
def _floor_to_int(value: Decimal) -> int:
try:
return int(value.to_integral_value(rounding=ROUND_DOWN))
except Exception:
return 0
def _sign_params(self, *, method: str, base_url: str, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
signed = dict(params or {})
signed["AccessKeyId"] = self.api_key
signed["SignatureMethod"] = "HmacSHA256"
signed["SignatureVersion"] = "2"
signed["Timestamp"] = self._utc_ts()
encoded = urlencode(sorted((str(k), str(v)) for k, v in signed.items()))
host = urlparse(base_url).netloc
payload = "\n".join([str(method or "GET").upper(), host, path, encoded])
digest = hmac.new(self.secret_key.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).digest()
signed["Signature"] = base64.b64encode(digest).decode("utf-8")
return signed
def _spot_public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
old_base = self.base_url
self.base_url = self.spot_base_url
try:
code, data, text = self._request(method, path, params=params)
finally:
self.base_url = old_base
if code >= 400:
raise LiveTradingError(f"HTX spot HTTP {code}: {text[:500]}")
if isinstance(data, dict) and str(data.get("status") or "").lower() == "error":
raise LiveTradingError(f"HTX spot error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def _spot_private_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
signed_params = self._sign_params(method=method, base_url=self.spot_base_url, path=path, params=params or {})
old_base = self.base_url
self.base_url = self.spot_base_url
try:
code, data, text = self._request(method, path, params=signed_params, json_body=json_body)
finally:
self.base_url = old_base
if code >= 400:
raise LiveTradingError(f"HTX spot HTTP {code}: {text[:500]}")
if isinstance(data, dict) and str(data.get("status") or "").lower() == "error":
raise LiveTradingError(f"HTX spot error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def _swap_private_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
signed_params = self._sign_params(method=method, base_url=self.futures_base_url, path=path, params=params or {})
old_base = self.base_url
self.base_url = self.futures_base_url
try:
code, data, text = self._request(method, path, params=signed_params, json_body=json_body)
finally:
self.base_url = old_base
if code >= 400:
raise LiveTradingError(f"HTX swap HTTP {code}: {text[:500]}")
if isinstance(data, dict) and str(data.get("status") or "").lower() == "error":
raise LiveTradingError(f"HTX swap error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def _swap_public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
old_base = self.base_url
self.base_url = self.futures_base_url
try:
code, data, text = self._request(method, path, params=params)
finally:
self.base_url = old_base
if code >= 400:
raise LiveTradingError(f"HTX swap HTTP {code}: {text[:500]}")
if isinstance(data, dict) and str(data.get("status") or "").lower() == "error":
raise LiveTradingError(f"HTX swap error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def ping(self) -> bool:
try:
if self.market_type == "spot":
self._spot_public_request("GET", "/v1/common/timestamp")
else:
self._swap_public_request("GET", "/linear-swap-api/v1/swap_contract_info")
return True
except Exception:
return False
def _get_spot_account_id(self) -> str:
if self._spot_account_id:
return self._spot_account_id
raw = self._spot_private_request("GET", "/v1/account/accounts")
data = raw.get("data") or []
if isinstance(data, list):
for item in data:
if not isinstance(item, dict):
continue
if str(item.get("type") or "").lower() == "spot" and str(item.get("state") or "").lower() in ("working", ""):
self._spot_account_id = str(item.get("id") or "")
if self._spot_account_id:
return self._spot_account_id
for item in data:
if isinstance(item, dict) and item.get("id"):
self._spot_account_id = str(item.get("id"))
return self._spot_account_id
raise LiveTradingError("HTX spot account id not found")
def get_accounts(self) -> Any:
if self.market_type == "spot":
return self._spot_private_request("GET", "/v1/account/accounts")
return self.get_balance()
def get_balance(self) -> Any:
if self.market_type == "spot":
account_id = self._get_spot_account_id()
return self._spot_private_request("GET", f"/v1/account/accounts/{account_id}/balance")
# 1) v1 cross
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"})
data = raw.get("data")
if data:
return raw
except LiveTradingError as e:
logger.debug("HTX v1 cross account_info failed: %s", e)
# 2) v3 unified (unified / multi-asset collateral accounts)
try:
raw = self._swap_private_request("GET", "/linear-swap-api/v3/unified_account_info")
v3_code = raw.get("code")
if v3_code is not None and int(v3_code) == 200:
data = raw.get("data")
if isinstance(data, list) and data:
logger.info("HTX v3 unified_account_info succeeded")
return raw
else:
logger.debug("HTX v3 unified_account_info returned code=%s", v3_code)
except (LiveTradingError, Exception) as e:
logger.debug("HTX v3 unified_account_info failed: %s", e)
# 3) v1 isolated
try:
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_account_info", json_body={})
except LiveTradingError as e:
logger.warning("HTX all balance endpoints failed, last error: %s", e)
return {"data": []}
def get_positions(self, *, symbol: str = "") -> Any:
if self.market_type == "spot":
balance = self.get_balance()
items = (((balance.get("data") or {}).get("list")) if isinstance(balance, dict) else None) or []
base_asset = ""
if symbol:
base_asset = str(symbol).split("/", 1)[0].split(":", 1)[0].strip().upper()
rows = []
for item in items:
if not isinstance(item, dict):
continue
ccy = str(item.get("currency") or "").upper()
if not ccy or (base_asset and ccy != base_asset):
continue
bal = self._to_dec(item.get("balance") or "0")
if bal <= 0:
continue
rows.append({
"symbol": f"{ccy}/USDT",
"bal": float(bal),
"availBal": float(self._to_dec(item.get("balance") or "0")),
"cost_open": 0,
"profit_unreal": 0,
})
return {"data": rows}
body = {"contract_code": to_htx_contract_code(symbol)} if symbol else {}
# 1) v1 cross
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_position_info", json_body=body)
data = raw.get("data")
if data:
return raw
except LiveTradingError as e:
logger.debug("HTX v1 cross position_info failed: %s", e)
# 2) v1 isolated
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_position_info", json_body=body)
data = raw.get("data")
if data:
return raw
except LiveTradingError as e:
logger.debug("HTX v1 isolated position_info failed: %s", e)
# 3) v3 unified - extract positions from cross_swap sub-array
try:
raw = self._swap_private_request("GET", "/linear-swap-api/v3/unified_account_info")
v3_code = raw.get("code")
if v3_code is not None and int(v3_code) == 200:
v3_data = raw.get("data") or []
if isinstance(v3_data, list) and v3_data:
logger.info("HTX v3 unified_account_info for positions succeeded")
return raw
except (LiveTradingError, Exception) as e:
logger.debug("HTX v3 unified_account_info (positions) failed: %s", e)
logger.warning("HTX all position endpoints failed for symbol=%s", symbol)
return {"data": []}
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
if self.market_type == "spot":
raw = self._spot_public_request("GET", "/market/detail/merged", params={"symbol": to_htx_spot_symbol(symbol)})
else:
raw = self._swap_public_request("GET", "/linear-swap-ex/market/detail/merged", params={"contract_code": to_htx_contract_code(symbol)})
tick = raw.get("tick") if isinstance(raw, dict) else {}
return tick if isinstance(tick, dict) else {}
def get_contract_info(self, *, symbol: str) -> Dict[str, Any]:
key = to_htx_contract_code(symbol)
cached = self._contract_cache.get(key)
now = time.time()
if cached:
ts, obj = cached
if obj and (now - float(ts or 0)) <= float(self._contract_cache_ttl_sec or 300):
return obj
raw = self._swap_public_request("GET", "/linear-swap-api/v1/swap_contract_info", params={"contract_code": key})
data = raw.get("data") or []
obj = data[0] if isinstance(data, list) and data and isinstance(data[0], dict) else {}
if obj:
self._contract_cache[key] = (now, obj)
return obj
def _base_to_contracts(self, *, symbol: str, qty: float) -> int:
req = self._to_dec(qty)
if req <= 0:
return 0
info = self.get_contract_info(symbol=symbol) or {}
contract_size = self._to_dec(info.get("contract_size") or info.get("contractSize") or "1")
if contract_size <= 0:
contract_size = Decimal("1")
contracts = req / contract_size
val = self._floor_to_int(contracts)
return val if val > 0 else 1
def set_leverage(self, *, symbol: str, leverage: float) -> bool:
if self.market_type == "spot":
return False
contract_code = to_htx_contract_code(symbol)
try:
lv = int(float(leverage or 1))
except Exception:
lv = 1
if lv < 1:
lv = 1
try:
self._swap_private_request(
"POST",
"/linear-swap-api/v1/swap_cross_switch_lever_rate",
json_body={"contract_code": contract_code, "lever_rate": lv, "margin_account": "USDT"},
)
self._lever_cache[contract_code] = lv
return True
except Exception:
try:
self._swap_private_request(
"POST",
"/linear-swap-api/v1/swap_switch_lever_rate",
json_body={"contract_code": contract_code, "lever_rate": lv},
)
self._lever_cache[contract_code] = lv
return True
except Exception:
return False
def place_market_order(
self,
*,
symbol: str,
side: str,
qty: float,
reduce_only: bool = False,
pos_side: str = "",
client_order_id: Optional[str] = None,
) -> LiveOrderResult:
if self.market_type == "spot":
account_id = self._get_spot_account_id()
sd = str(side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
amount = float(qty or 0)
if amount <= 0:
raise LiveTradingError("Invalid qty")
order_type = f"{sd}-market"
if sd == "buy":
tick = self.get_ticker(symbol=symbol)
last = float(tick.get("close") or tick.get("price") or tick.get("lastPrice") or 0)
if last <= 0:
raise LiveTradingError("HTX spot market buy requires latest price for qty->value conversion")
amount = amount * last
body = {
"account-id": account_id,
"symbol": to_htx_spot_symbol(symbol),
"type": order_type,
"amount": f"{amount:.12f}".rstrip("0").rstrip("."),
"source": "spot-api",
}
formatted_client_order_id = self._format_spot_client_order_id(client_order_id)
if formatted_client_order_id:
body["client-order-id"] = formatted_client_order_id
raw = self._spot_private_request("POST", "/v1/order/orders/place", json_body=body)
data = raw.get("data")
oid = str(data or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
contract_code = to_htx_contract_code(symbol)
volume = self._base_to_contracts(symbol=symbol, qty=qty)
if volume <= 0:
raise LiveTradingError("Invalid HTX swap volume")
sd = str(side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
offset = "close" if reduce_only else "open"
lever_rate = int(self._lever_cache.get(contract_code) or 5)
body: Dict[str, Any] = {
"contract_code": contract_code,
"volume": volume,
"direction": sd,
"offset": offset,
"lever_rate": lever_rate,
"order_price_type": "opponent",
}
if self.broker_id:
body["channel_code"] = self.broker_id
swap_coid = self._format_swap_client_order_id(client_order_id)
if swap_coid is not None:
body["client_order_id"] = swap_coid
cross_body = dict(body)
cross_body["margin_account"] = "USDT"
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_order", json_body=cross_body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
except LiveTradingError as e:
logger.info("HTX swap_cross_order failed, trying swap_order: %s", e)
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
def place_limit_order(
self,
*,
symbol: str,
side: str,
size: float,
price: float,
reduce_only: bool = False,
pos_side: str = "",
client_order_id: Optional[str] = None,
) -> LiveOrderResult:
px = float(price or 0)
qty = float(size or 0)
if px <= 0 or qty <= 0:
raise LiveTradingError("Invalid size/price")
sd = str(side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
if self.market_type == "spot":
account_id = self._get_spot_account_id()
body = {
"account-id": account_id,
"symbol": to_htx_spot_symbol(symbol),
"type": f"{sd}-limit",
"amount": f"{qty:.12f}".rstrip("0").rstrip("."),
"price": f"{px:.12f}".rstrip("0").rstrip("."),
"source": "spot-api",
}
formatted_client_order_id = self._format_spot_client_order_id(client_order_id)
if formatted_client_order_id:
body["client-order-id"] = formatted_client_order_id
raw = self._spot_private_request("POST", "/v1/order/orders/place", json_body=body)
data = raw.get("data")
oid = str(data or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
contract_code = to_htx_contract_code(symbol)
volume = self._base_to_contracts(symbol=symbol, qty=qty)
lever_rate = int(self._lever_cache.get(contract_code) or 5)
body: Dict[str, Any] = {
"contract_code": contract_code,
"volume": volume,
"direction": sd,
"offset": "close" if reduce_only else "open",
"lever_rate": lever_rate,
"price": px,
"order_price_type": "limit",
}
if self.broker_id:
body["channel_code"] = self.broker_id
swap_coid = self._format_swap_client_order_id(client_order_id)
if swap_coid is not None:
body["client_order_id"] = swap_coid
cross_body = dict(body)
cross_body["margin_account"] = "USDT"
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_order", json_body=cross_body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
except LiveTradingError:
pass
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
if self.market_type == "spot":
if order_id:
return self._spot_private_request("POST", f"/v1/order/orders/{str(order_id)}/submitcancel")
if client_order_id:
return self._spot_private_request("POST", "/v1/order/orders/submitCancelClientOrder", json_body={"client-order-id": str(client_order_id)})
raise LiveTradingError("HTX cancel_order requires order_id or client_order_id")
body: Dict[str, Any] = {"contract_code": to_htx_contract_code(symbol)}
if order_id:
body["order_id"] = str(order_id)
elif client_order_id:
body["client_order_id"] = str(client_order_id)
else:
raise LiveTradingError("HTX cancel_order requires order_id or client_order_id")
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_cancel", json_body=body)
def get_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
if self.market_type == "spot":
if order_id:
raw = self._spot_private_request("GET", f"/v1/order/orders/{str(order_id)}")
data = raw.get("data") if isinstance(raw, dict) else {}
return data if isinstance(data, dict) else {}
if client_order_id:
raw = self._spot_private_request("GET", "/v1/order/orders/getClientOrder", params={"clientOrderId": str(client_order_id)})
data = raw.get("data") if isinstance(raw, dict) else {}
return data if isinstance(data, dict) else {}
raise LiveTradingError("HTX get_order requires order_id or client_order_id")
body: Dict[str, Any] = {"contract_code": to_htx_contract_code(symbol)}
if order_id:
body["order_id"] = str(order_id)
elif client_order_id:
body["client_order_id"] = str(client_order_id)
else:
raise LiveTradingError("HTX get_order requires order_id or client_order_id")
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order_info", json_body=body)
data = raw.get("data") or []
if isinstance(data, list) and data and isinstance(data[0], dict):
return data[0]
return {}
def wait_for_fill(
self,
*,
symbol: str,
order_id: str = "",
client_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:
last = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")) or {}
except Exception:
last = last or {}
filled = 0.0
avg_price = 0.0
fee = 0.0
fee_ccy = "USDT"
status = str(last.get("status") or last.get("state") or "")
try:
filled = float(
last.get("field-amount") or
last.get("filled_amount") or
last.get("trade_volume") or
last.get("trade_volume_avg") or
0.0
)
except Exception:
filled = 0.0
try:
avg_price = float(
last.get("field-cash-amount") or 0.0
)
if filled > 0 and avg_price > 0:
avg_price = avg_price / filled
else:
avg_price = float(last.get("field-avg-price") or last.get("trade_avg_price") or last.get("price") or 0.0)
except Exception:
avg_price = 0.0
try:
fee = abs(float(last.get("fee") or last.get("trade_fee") or 0.0))
except Exception:
fee = 0.0
fee_ccy = str(last.get("fee_asset") or last.get("fee_currency") or fee_ccy or "").strip() or "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 str(status).lower() in ("filled", "partial-filled", "submitted", "canceled", "cancelled", "6", "7"):
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))