e666230d0b
Enable settings UI for mode/keys, gate strategy start when LIVE is not ready, and stop PM2 from forcing MODE=SIM. Co-authored-by: Cursor <cursoragent@cursor.com>
164 lines
5.6 KiB
Python
164 lines
5.6 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
|
|
|
|
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] = {}
|
|
|
|
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]]:
|
|
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)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
if str(data.get("code")) != "0":
|
|
raise RuntimeError(
|
|
f"OKX trade error code={data.get('code')} msg={data.get('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:
|
|
fee = abs(safe_float(last.get("fee")) or 0.0)
|
|
sz = safe_float(last.get("accFillSz")) or safe_float(last.get("sz")) or 0.0
|
|
return LiveFill(
|
|
inst_id=inst_id,
|
|
side=str(last.get("side") or ""),
|
|
avg_px=float(avg),
|
|
sz=float(sz),
|
|
fee=float(fee),
|
|
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}")
|