Files
eth_hedge_sim/backend/app/live/okx_trade.py
T

438 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)
# OKXafter=更早时间戳边界,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