"""OKX V5 私有交易 REST(下单)。""" from __future__ import annotations import base64 import hashlib import hmac import json import logging import time from dataclasses import dataclass from typing import Any import httpx from ..config import Settings, get_settings from ..exchange.okx.parse import safe_float from .rate_limit import RateLimitError, get_throttle, parse_retry_after_header logger = logging.getLogger(__name__) @dataclass(slots=True) class LiveFill: inst_id: str side: str avg_px: float sz: float # 张或币,取决于合约 fee: float ord_id: str raw: dict[str, Any] class OkxTradeClient: def __init__(self, settings: Settings | None = None) -> None: self.settings = settings or get_settings() proxy = (self.settings.okx_http_proxy or "").strip() or None self._client = httpx.Client( base_url=self.settings.okx_rest_base.rstrip("/"), timeout=20.0, proxy=proxy, headers={"Accept": "application/json", "User-Agent": "eth-hedge-live/0.1"}, ) self._ct_val_cache: dict[str, float] = {} self._throttle = get_throttle("okx_trade", min_interval_sec=1.0) def close(self) -> None: self._client.close() def _ts(self) -> str: # OKX: ISO8601 with milliseconds return ( time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime()) + f".{int(time.time() * 1000) % 1000:03d}Z" ) def _sign(self, ts: str, method: str, path: str, body: str) -> str: secret = (self.settings.okx_api_secret or "").encode("utf-8") msg = f"{ts}{method.upper()}{path}{body}".encode("utf-8") dig = hmac.new(secret, msg, hashlib.sha256).digest() return base64.b64encode(dig).decode("utf-8") def _headers(self, ts: str, sign: str) -> dict[str, str]: return { "OK-ACCESS-KEY": self.settings.okx_api_key or "", "OK-ACCESS-SIGN": sign, "OK-ACCESS-TIMESTAMP": ts, "OK-ACCESS-PASSPHRASE": self.settings.okx_api_passphrase or "", "Content-Type": "application/json", } def _request( self, method: str, path: str, body: dict[str, Any] | None = None ) -> list[dict[str, Any]]: self._throttle.before_request() payload = "" if body is None else json.dumps(body, separators=(",", ":")) ts = self._ts() sign = self._sign(ts, method, path, payload) headers = self._headers(ts, sign) if method.upper() == "GET": r = self._client.get(path, headers=headers) else: r = self._client.request(method.upper(), path, content=payload, headers=headers) if r.status_code in (418, 429): ra = parse_retry_after_header(r.headers) self._throttle.mark_http(r.status_code, ra) raise RateLimitError( f"OKX HTTP {r.status_code}: {r.text[:200]}", retry_after=self._throttle.remaining_cooldown(), ) try: r.raise_for_status() except httpx.HTTPStatusError as e: raise RuntimeError(f"OKX HTTP {r.status_code}: {r.text[:300]}") from e data = r.json() code = str(data.get("code") or "") msg = str(data.get("msg") or "") # OKX 业务层频率类错误 if code != "0": low = f"{code} {msg}".lower() if code in ("50011", "50061") or "too many" in low or "频率" in msg: self._throttle.mark_seconds(20.0) raise RateLimitError( f"OKX trade rate-limited code={code} msg={msg}", retry_after=self._throttle.remaining_cooldown(), ) raise RuntimeError( f"OKX trade error code={code} msg={msg} data={data.get('data')}" ) rows = data.get("data") or [] return [x for x in rows if isinstance(x, dict)] def get_ct_val(self, inst_id: str, *, inst_type: str) -> float: if inst_id in self._ct_val_cache: return self._ct_val_cache[inst_id] r = self._client.get( "/api/v5/public/instruments", params={"instType": inst_type, "instId": inst_id}, ) r.raise_for_status() body = r.json() rows = body.get("data") or [] for row in rows: if str(row.get("instId")) == inst_id: v = safe_float(row.get("ctVal")) or safe_float(row.get("ctMult")) if v and v > 0: self._ct_val_cache[inst_id] = float(v) return float(v) default = 0.01 self._ct_val_cache[inst_id] = default return default def place_market( self, *, inst_id: str, side: str, # buy|sell sz: str, td_mode: str, pos_side: str | None = None, reduce_only: bool = False, ) -> LiveFill: body: dict[str, Any] = { "instId": inst_id, "tdMode": td_mode, "side": side, "ordType": "market", "sz": str(sz), } if pos_side: body["posSide"] = pos_side if reduce_only: body["reduceOnly"] = True rows = self._request("POST", "/api/v5/trade/order", body) if not rows: raise RuntimeError("OKX 下单无返回") ord_id = str(rows[0].get("ordId") or "") # 查单取均价 fill = self._wait_fill(inst_id, ord_id) return fill def _wait_fill(self, inst_id: str, ord_id: str, *, tries: int = 8) -> LiveFill: path = f"/api/v5/trade/order?instId={inst_id}&ordId={ord_id}" last: dict[str, Any] = {} for _ in range(tries): rows = self._request("GET", path) if rows: last = rows[0] state = str(last.get("state") or "") avg = safe_float(last.get("avgPx")) if state in ("filled", "partially_filled") and avg and avg > 0: sz = safe_float(last.get("accFillSz")) or safe_float(last.get("sz")) or 0.0 fee = abs(safe_float(last.get("fee")) or 0.0) fee_ccy = str(last.get("feeCcy") or "USDT") # 订单上 fee 常为空,再查成交明细 if fee <= 0 and ord_id: fee, fee_ccy = self.sum_fill_fees(inst_id, ord_id) from .money import abs_fee_usdt return LiveFill( inst_id=inst_id, side=str(last.get("side") or ""), avg_px=float(avg), sz=float(sz), fee=abs_fee_usdt(fee, fee_ccy), ord_id=ord_id, raw=last, ) if state in ("canceled", "failed"): raise RuntimeError(f"OKX 订单失败 state={state} {last}") time.sleep(0.25) raise RuntimeError(f"OKX 订单未成交 ordId={ord_id} last={last}") def sum_fill_fees(self, inst_id: str, ord_id: str) -> tuple[float, str]: """成交明细手续费合计(原币种金额, 币种)。""" path = f"/api/v5/trade/fills?instId={inst_id}&ordId={ord_id}" try: rows = self._request("GET", path) except Exception as e: logger.warning("okx fills fee query failed: %s", e) return 0.0, "USDT" total = 0.0 ccy = "USDT" for row in rows: f = abs(safe_float(row.get("fee")) or 0.0) total += f if row.get("feeCcy"): ccy = str(row.get("feeCcy")) return total, ccy def get_perp_upl_usdt(self, inst_id: str, *, pos_side: str | None = None) -> float | None: """当前永续未实现盈亏(USDT,1:1)。""" from .money import to_usdt try: rows = self._request( "GET", f"/api/v5/account/positions?instId={inst_id}" ) except Exception as e: logger.warning("okx positions failed: %s", e) return None want = (pos_side or "").strip().lower() for row in rows: ps = str(row.get("posSide") or "").lower() pos = safe_float(row.get("pos")) or 0.0 if abs(pos) < 1e-12: continue if want and want not in ("net", "") and ps and ps != want and ps != "net": continue upl = safe_float(row.get("upl")) if upl is None: continue ccy = str(row.get("ccy") or row.get("settleCcy") or "USDT") return to_usdt(float(upl), ccy) return 0.0 def get_funding_usdt( self, inst_id: str, *, begin_ms: int, end_ms: int | None = None ) -> float: """资金费合计(已计入账户的 signed 金额,USDT 1:1)。付费为负。""" from .money import to_usdt end = int(end_ms or int(time.time() * 1000)) # type=8 funding fee path = ( f"/api/v5/account/bills?instType=SWAP&instId={inst_id}" f"&type=8&begin={int(begin_ms)}&end={end}" ) total = 0.0 try: rows = self._request("GET", path) except Exception as e: logger.warning("okx funding bills failed: %s", e) return 0.0 for row in rows: # balChg / pnl 视接口;资金费常用 pnl 或 balChg raw = safe_float(row.get("pnl")) if raw is None: raw = safe_float(row.get("balChg")) if raw is None: continue ccy = str(row.get("ccy") or "USDT") total += to_usdt(float(raw), ccy) return total def get_closed_perp_pnl_usdt( self, inst_id: str, *, begin_ms: int, end_ms: int | None = None ) -> float | None: """平仓后从历史仓位取已实现盈亏(不含手续费;含部分仓位盈亏)。""" from .money import to_usdt end = int(end_ms or int(time.time() * 1000)) path = ( f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}" f"&before={end}&after={int(begin_ms)}" ) try: # positions-history 用 GET query;部分环境用 before/after 语义相反,失败则返回 None rows = self._request( "GET", f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}", ) except Exception as e: logger.warning("okx positions-history failed: %s", e) return None total = 0.0 hit = False for row in rows: u_time = int(safe_float(row.get("uTime")) or safe_float(row.get("cTime")) or 0) if u_time and (u_time < int(begin_ms) - 60_000 or u_time > end + 60_000): continue rpnl = safe_float(row.get("realizedPnl")) if rpnl is None: rpnl = safe_float(row.get("pnl")) if rpnl is None: continue hit = True ccy = str(row.get("ccy") or "USDT") total += to_usdt(float(rpnl), ccy) return total if hit else None