0cf3756b09
Prevent duplicate opens by atomically claiming an opening slot, verifying exchange perp is flat before live orders, setting leverage from ledger, and preferring exchange position size when closing perps. Co-authored-by: Cursor <cursoragent@cursor.com>
337 lines
12 KiB
Python
337 lines
12 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 _wait_fill(self, inst_id: str, ord_id: str, *, tries: int = 20) -> 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 == "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")
|
|
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.3)
|
|
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 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))
|
|
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
|
|
|