469e7a258a
Co-authored-by: Cursor <cursoragent@cursor.com>
438 lines
16 KiB
Python
438 lines
16 KiB
Python
"""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)
|
||
raise RuntimeError(f"OKX 无法取得合约面值 ctVal: {inst_id} instType={inst_type}")
|
||
|
||
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 place_ioc(
|
||
self,
|
||
*,
|
||
inst_id: str,
|
||
side: str, # buy|sell
|
||
sz: str,
|
||
px: float | str,
|
||
td_mode: str,
|
||
pos_side: str | None = None,
|
||
reduce_only: bool = False,
|
||
) -> LiveFill:
|
||
"""限价 IOC:残留回收等场景按指定买一/卖一吃单,不成交部分立即取消。"""
|
||
body: dict[str, Any] = {
|
||
"instId": inst_id,
|
||
"tdMode": td_mode,
|
||
"side": side,
|
||
"ordType": "ioc",
|
||
"sz": str(sz),
|
||
"px": str(px),
|
||
}
|
||
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 IOC 下单无返回")
|
||
ord_id = str(rows[0].get("ordId") or "")
|
||
return self._wait_fill(inst_id, ord_id, allow_partial=True)
|
||
|
||
def _fill_from_order_row(self, inst_id: str, ord_id: str, row: dict[str, Any]) -> LiveFill:
|
||
avg = safe_float(row.get("avgPx")) or 0.0
|
||
sz = safe_float(row.get("accFillSz")) or safe_float(row.get("sz")) or 0.0
|
||
fee = abs(safe_float(row.get("fee")) or 0.0)
|
||
fee_ccy = str(row.get("feeCcy") or "USDT")
|
||
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(row.get("side") or ""),
|
||
avg_px=float(avg),
|
||
sz=float(sz),
|
||
fee=abs_fee_usdt(fee, fee_ccy),
|
||
ord_id=ord_id,
|
||
raw=row,
|
||
)
|
||
|
||
def _wait_fill(
|
||
self,
|
||
inst_id: str,
|
||
ord_id: str,
|
||
*,
|
||
tries: int = 40,
|
||
allow_partial: bool = False,
|
||
) -> 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"))
|
||
acc = safe_float(last.get("accFillSz")) or 0.0
|
||
# 仅完全成交;部分成交继续等,避免账本张数与交易所不一致
|
||
if state == "filled" and avg and avg > 0:
|
||
return self._fill_from_order_row(inst_id, ord_id, last)
|
||
if state in ("canceled", "failed"):
|
||
# IOC:未成交部分取消;若已有成交量则按部分成交入账
|
||
if (
|
||
allow_partial
|
||
and acc > 1e-12
|
||
and avg
|
||
and avg > 0
|
||
):
|
||
return self._fill_from_order_row(inst_id, ord_id, last)
|
||
raise RuntimeError(f"OKX 订单失败 state={state} {last}")
|
||
time.sleep(0.3)
|
||
# 超时兜底:仅接受完全成交;部分成交不得当全成记账(会错张数/对冲)
|
||
state = str(last.get("state") or "")
|
||
avg = safe_float(last.get("avgPx"))
|
||
acc = safe_float(last.get("accFillSz")) or 0.0
|
||
if state == "filled" and avg and avg > 0:
|
||
logger.warning(
|
||
"OKX fill wait timeout but order filled ordId=%s",
|
||
ord_id,
|
||
)
|
||
return self._fill_from_order_row(inst_id, ord_id, last)
|
||
if allow_partial and acc > 1e-12 and avg and avg > 0:
|
||
logger.warning(
|
||
"OKX IOC partial fill on timeout ordId=%s acc=%s",
|
||
ord_id,
|
||
acc,
|
||
)
|
||
return self._fill_from_order_row(inst_id, ord_id, last)
|
||
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_perp_pos_sz(self, inst_id: str, *, pos_side: str | None = None) -> float | None:
|
||
"""当前永续绝对持仓张数。"""
|
||
try:
|
||
rows = self._request(
|
||
"GET", f"/api/v5/account/positions?instId={inst_id}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning("okx get_perp_pos_sz 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
|
||
return abs(float(pos))
|
||
return 0.0
|
||
|
||
def get_option_pos_sz(self, inst_id: str) -> float | None:
|
||
"""期权绝对持仓张数。"""
|
||
try:
|
||
rows = self._request(
|
||
"GET",
|
||
f"/api/v5/account/positions?instType=OPTION&instId={inst_id}",
|
||
)
|
||
except Exception as e:
|
||
logger.warning("okx get_option_pos_sz failed: %s", e)
|
||
return None
|
||
total = 0.0
|
||
for row in rows:
|
||
pos = safe_float(row.get("pos")) or 0.0
|
||
total += abs(float(pos))
|
||
return total
|
||
|
||
def any_option_pos_abs(self) -> float | None:
|
||
"""账户任意期权绝对持仓张数合计。"""
|
||
try:
|
||
rows = self._request("GET", "/api/v5/account/positions?instType=OPTION")
|
||
except Exception as e:
|
||
logger.warning("okx any_option_pos_abs failed: %s", e)
|
||
return None
|
||
total = 0.0
|
||
for row in rows:
|
||
pos = safe_float(row.get("pos")) or 0.0
|
||
total += abs(float(pos))
|
||
return total
|
||
|
||
def set_leverage(
|
||
self,
|
||
inst_id: str,
|
||
leverage: float,
|
||
*,
|
||
mgn_mode: str = "cross",
|
||
pos_side: str | None = None,
|
||
) -> None:
|
||
body: dict[str, Any] = {
|
||
"instId": inst_id,
|
||
"lever": str(leverage),
|
||
"mgnMode": mgn_mode,
|
||
}
|
||
if pos_side:
|
||
body["posSide"] = pos_side
|
||
self._request("POST", "/api/v5/account/set-leverage", body)
|
||
|
||
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))
|
||
begin = int(begin_ms)
|
||
# OKX:after=更早时间戳边界,before=更晚;再本地按 uTime 过滤兜底
|
||
path = (
|
||
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}"
|
||
f"&after={begin}&before={end}"
|
||
)
|
||
try:
|
||
rows = self._request("GET", path)
|
||
except Exception as e:
|
||
logger.warning("okx positions-history failed: %s", e)
|
||
try:
|
||
rows = self._request(
|
||
"GET",
|
||
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}",
|
||
)
|
||
except Exception as e2:
|
||
logger.warning("okx positions-history fallback failed: %s", e2)
|
||
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 < begin - 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
|
||
|