Split exchange and strategy modules for future Binance support.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .adapter import OkxExchange
|
||||
|
||||
__all__ = ["OkxExchange"]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""OKX 交易所适配器:只负责行情与合约,不含策略选约。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Sequence
|
||||
|
||||
from ...config import Settings, get_settings
|
||||
from ..book_cache import BookCache
|
||||
from ..types import BookLevel, MarketSnapshot, OptionPair, Quote
|
||||
from .parse import rows_to_option_contracts, safe_float
|
||||
from .rest import OkxRestClient
|
||||
from .ws import OkxPublicWs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OkxExchange:
|
||||
name = "okx"
|
||||
|
||||
def __init__(self, settings: Settings | None = None) -> None:
|
||||
self.settings = settings or get_settings()
|
||||
self.cache = BookCache()
|
||||
proxy = self.settings.okx_http_proxy or None
|
||||
self.rest = OkxRestClient(self.settings.okx_rest_base, proxy=proxy)
|
||||
self.ws = OkxPublicWs(self.settings.okx_ws_public, self.cache, proxy=proxy)
|
||||
self._started = False
|
||||
self._ct_cache: dict[str, float] = {}
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._started:
|
||||
return
|
||||
self._started = True
|
||||
await self.ws.start()
|
||||
logger.info("OKX exchange started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._started = False
|
||||
await self.ws.stop()
|
||||
self.rest.close()
|
||||
logger.info("OKX exchange stopped")
|
||||
|
||||
def list_option_contracts(self, family: str) -> list[dict[str, Any]]:
|
||||
rows = self.rest.fetch_option_instruments(family)
|
||||
contracts = rows_to_option_contracts(rows)
|
||||
for c in contracts:
|
||||
if c.get("ct_mult"):
|
||||
self._ct_cache[str(c["inst_id"])] = float(c["ct_mult"])
|
||||
return contracts
|
||||
|
||||
def fetch_index(self, index_id: str) -> float | None:
|
||||
return self.rest.fetch_index_ticker(index_id)
|
||||
|
||||
def fetch_mark(self, inst_id: str) -> float | None:
|
||||
return self.rest.fetch_mark_price(inst_id)
|
||||
|
||||
def fetch_book(
|
||||
self, inst_id: str, depth: int = 5
|
||||
) -> tuple[list[BookLevel], list[BookLevel], int | None]:
|
||||
return self.rest.fetch_books(inst_id, sz=depth)
|
||||
|
||||
def get_ct_mult(self, option_inst_id: str, family: str, default: float) -> float:
|
||||
if option_inst_id in self._ct_cache:
|
||||
return self._ct_cache[option_inst_id]
|
||||
try:
|
||||
rows = self.rest.fetch_instruments(inst_type="OPTION", inst_family=family)
|
||||
for r in rows:
|
||||
if str(r.get("instId")) == option_inst_id:
|
||||
m = safe_float(r.get("ctMult"))
|
||||
if m and m > 0:
|
||||
self._ct_cache[option_inst_id] = float(m)
|
||||
return float(m)
|
||||
except Exception:
|
||||
pass
|
||||
return float(default)
|
||||
|
||||
def set_pair(self, pair: OptionPair | None) -> None:
|
||||
self.cache.set_pair(pair)
|
||||
|
||||
def warm_and_subscribe(self, inst_ids: Sequence[str]) -> None:
|
||||
ids = [i for i in inst_ids if i]
|
||||
for inst in ids:
|
||||
bids, asks, ts = self.rest.fetch_books(inst, sz=5)
|
||||
self.cache.upsert_book(inst, bids=bids, asks=asks, ts_ms=ts)
|
||||
mp = self.rest.fetch_mark_price(inst)
|
||||
if mp:
|
||||
self.cache.set_mark_px(inst, mp)
|
||||
keep = set(ids)
|
||||
self.cache.drop_except(keep)
|
||||
self.ws.set_instruments(ids)
|
||||
|
||||
async def resubscribe(self, inst_ids: Sequence[str]) -> None:
|
||||
await self.ws.resubscribe([i for i in inst_ids if i])
|
||||
|
||||
def quote(self, inst_id: str) -> Quote | None:
|
||||
return self.cache.get(inst_id)
|
||||
|
||||
def snapshot(self, perp_inst_id: str) -> MarketSnapshot:
|
||||
return self.cache.snapshot(perp_inst_id)
|
||||
|
||||
def snapshot_dict(self, perp_inst_id: str) -> dict[str, Any]:
|
||||
return self.snapshot(perp_inst_id).to_dict()
|
||||
|
||||
def set_index_px(self, px: float | None) -> None:
|
||||
self.cache.set_index_px(px)
|
||||
|
||||
def set_mark_px(self, inst_id: str, mark_px: float | None) -> None:
|
||||
self.cache.set_mark_px(inst_id, mark_px)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""OKX 合约 ID / 到期解析(交易所专属)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
_DATE_RE = re.compile(r"^\d{6}$")
|
||||
|
||||
|
||||
def safe_float(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_option_inst_id(inst_id: str) -> tuple[str | None, float | None, str | None]:
|
||||
"""ETH-USD_UM-YYMMDD-STRIKE-C → (YYMMDD, strike, C|P)."""
|
||||
parts = (inst_id or "").strip().split("-")
|
||||
if len(parts) < 5:
|
||||
return None, None, None
|
||||
ymd = parts[-3]
|
||||
strike = safe_float(parts[-2])
|
||||
opt = parts[-1].upper()
|
||||
if not _DATE_RE.fullmatch(ymd) or strike is None or opt not in ("C", "P"):
|
||||
return None, None, None
|
||||
return ymd, strike, opt
|
||||
|
||||
|
||||
def expiry_ms_from_ymd(ymd: str) -> int:
|
||||
"""OKX 期权到期:当日 08:00 UTC = 上海 16:00。"""
|
||||
yy, mm, dd = int(ymd[0:2]), int(ymd[2:4]), int(ymd[4:6])
|
||||
dt = datetime(2000 + yy, mm, dd, 8, 0, 0, tzinfo=timezone.utc)
|
||||
return int(dt.timestamp() * 1000)
|
||||
|
||||
|
||||
def rows_to_option_contracts(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
归一化为策略层可用的中性结构:
|
||||
{inst_id, expiry_ymd, strike, side, ct_mult}
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
state = str(row.get("state") or "live").lower()
|
||||
if state and state != "live":
|
||||
continue
|
||||
inst_id = str(row.get("instId") or "")
|
||||
y, stk, opt = parse_option_inst_id(inst_id)
|
||||
if y is None or stk is None or opt is None:
|
||||
exp = safe_float(row.get("expTime"))
|
||||
if exp:
|
||||
ms = int(exp) if exp > 10_000_000_000 else int(exp * 1000)
|
||||
y = datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%y%m%d")
|
||||
stk = safe_float(row.get("stk"))
|
||||
opt_raw = str(row.get("optType") or "").upper()
|
||||
opt = opt_raw if opt_raw in ("C", "P") else None
|
||||
if not inst_id or not y or stk is None or opt not in ("C", "P"):
|
||||
continue
|
||||
ct = safe_float(row.get("ctMult"))
|
||||
out.append(
|
||||
{
|
||||
"inst_id": inst_id,
|
||||
"expiry_ymd": y,
|
||||
"expiry_ms": expiry_ms_from_ymd(y),
|
||||
"strike": float(stk),
|
||||
"side": opt,
|
||||
"ct_mult": float(ct) if ct and ct > 0 else None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,99 @@
|
||||
"""OKX REST 只读行情。不调用任何交易类接口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ..types import BookLevel
|
||||
from .parse import safe_float
|
||||
|
||||
|
||||
class OkxRestClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://www.okx.com",
|
||||
timeout: float = 15.0,
|
||||
proxy: str | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.proxy = (proxy or "").strip() or None
|
||||
self._client = httpx.Client(
|
||||
base_url=self.base_url,
|
||||
timeout=timeout,
|
||||
proxy=self.proxy,
|
||||
headers={"Accept": "application/json", "User-Agent": "eth-hedge-sim/0.1"},
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> OkxRestClient:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
self.close()
|
||||
|
||||
def _get(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||
r = self._client.get(path, params=params or {})
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
if str(body.get("code")) != "0":
|
||||
raise RuntimeError(f"OKX REST error code={body.get('code')} msg={body.get('msg')}")
|
||||
data = body.get("data") or []
|
||||
return [x for x in data if isinstance(x, dict)]
|
||||
|
||||
def fetch_instruments(self, *, inst_type: str, inst_family: str | None = None) -> list[dict[str, Any]]:
|
||||
params: dict[str, Any] = {"instType": inst_type}
|
||||
if inst_family:
|
||||
params["instFamily"] = inst_family
|
||||
return self._get("/api/v5/public/instruments", params)
|
||||
|
||||
def fetch_option_instruments(self, inst_family: str) -> list[dict[str, Any]]:
|
||||
rows = self.fetch_instruments(inst_type="OPTION", inst_family=inst_family)
|
||||
return [r for r in rows if str(r.get("state") or "").lower() == "live"]
|
||||
|
||||
def fetch_index_ticker(self, inst_id: str) -> float | None:
|
||||
rows = self._get("/api/v5/market/index-tickers", {"instId": inst_id})
|
||||
if not rows:
|
||||
return None
|
||||
return safe_float(rows[0].get("idxPx"))
|
||||
|
||||
def fetch_mark_price(self, inst_id: str) -> float | None:
|
||||
rows = self._get("/api/v5/public/mark-price", {"instId": inst_id})
|
||||
if not rows:
|
||||
t = self._get("/api/v5/market/ticker", {"instId": inst_id})
|
||||
if not t:
|
||||
return None
|
||||
return safe_float(t[0].get("markPx")) or safe_float(t[0].get("last"))
|
||||
return safe_float(rows[0].get("markPx"))
|
||||
|
||||
def fetch_books(self, inst_id: str, sz: int = 5) -> tuple[list[BookLevel], list[BookLevel], int | None]:
|
||||
rows = self._get(
|
||||
"/api/v5/market/books",
|
||||
{"instId": inst_id, "sz": str(max(1, min(int(sz), 400)))},
|
||||
)
|
||||
if not rows:
|
||||
return [], [], None
|
||||
row = rows[0]
|
||||
ts = safe_float(row.get("ts"))
|
||||
ts_ms = int(ts) if ts is not None else None
|
||||
return (
|
||||
_levels(row.get("bids") or []),
|
||||
_levels(row.get("asks") or []),
|
||||
ts_ms,
|
||||
)
|
||||
|
||||
|
||||
def _levels(raw: list[Any]) -> list[BookLevel]:
|
||||
out: list[BookLevel] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, (list, tuple)) or len(item) < 2:
|
||||
continue
|
||||
px = safe_float(item[0])
|
||||
sz = safe_float(item[1])
|
||||
if px is None or sz is None or px <= 0 or sz <= 0:
|
||||
continue
|
||||
out.append(BookLevel(px=px, sz=sz))
|
||||
return out
|
||||
@@ -0,0 +1,207 @@
|
||||
"""OKX 公共 WebSocket:永续 + 期权 books5 / mark-price。只读。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import websockets
|
||||
from websockets.asyncio.client import ClientConnection
|
||||
|
||||
from ..book_cache import BookCache
|
||||
from ..types import BookLevel
|
||||
from .parse import safe_float
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OkxPublicWs:
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
cache: BookCache,
|
||||
*,
|
||||
proxy: str | None = None,
|
||||
ping_interval: float = 20.0,
|
||||
) -> None:
|
||||
self.url = url
|
||||
self.cache = cache
|
||||
self.proxy = (proxy or "").strip() or None
|
||||
self.ping_interval = ping_interval
|
||||
self._inst_ids: list[str] = []
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._stop = asyncio.Event()
|
||||
self._subscribed: set[str] = set()
|
||||
|
||||
def set_instruments(self, inst_ids: list[str]) -> None:
|
||||
self._inst_ids = [i for i in inst_ids if i]
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._task = asyncio.create_task(self._run_forever(), name="okx-public-ws")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
self.cache.set_connected(False)
|
||||
|
||||
async def resubscribe(self, inst_ids: list[str]) -> None:
|
||||
self.set_instruments(inst_ids)
|
||||
self._stop.set()
|
||||
await asyncio.sleep(0)
|
||||
self._stop.clear()
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = asyncio.create_task(self._run_forever(), name="okx-public-ws")
|
||||
|
||||
async def _open_connection(self) -> ClientConnection:
|
||||
if not self.proxy:
|
||||
return await websockets.connect(
|
||||
self.url,
|
||||
ping_interval=None,
|
||||
max_size=2**22,
|
||||
open_timeout=20,
|
||||
)
|
||||
|
||||
from python_socks.async_.asyncio import Proxy
|
||||
|
||||
parsed = urlparse(self.url)
|
||||
host = parsed.hostname or "ws.okx.com"
|
||||
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
|
||||
sock = await Proxy.from_url(self.proxy).connect(dest_host=host, dest_port=port)
|
||||
return await websockets.connect(
|
||||
self.url,
|
||||
sock=sock,
|
||||
server_hostname=host,
|
||||
ping_interval=None,
|
||||
max_size=2**22,
|
||||
open_timeout=20,
|
||||
)
|
||||
|
||||
async def _run_forever(self) -> None:
|
||||
backoff = 1.0
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
async with await self._open_connection() as ws:
|
||||
self.cache.set_connected(True)
|
||||
backoff = 1.0
|
||||
await self._subscribe(ws)
|
||||
waiter = asyncio.create_task(self._stop.wait())
|
||||
reader = asyncio.create_task(self._read_loop(ws))
|
||||
pinger = asyncio.create_task(self._ping_loop(ws))
|
||||
done, pending = await asyncio.wait(
|
||||
{waiter, reader, pinger},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
for t in done:
|
||||
exc = t.exception()
|
||||
if exc and not isinstance(exc, asyncio.CancelledError):
|
||||
raise exc
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("OKX WS disconnected: %s", e)
|
||||
self.cache.set_connected(False)
|
||||
try:
|
||||
await asyncio.wait_for(self._stop.wait(), timeout=backoff)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
backoff = min(backoff * 2, 30.0)
|
||||
|
||||
self.cache.set_connected(False)
|
||||
|
||||
async def _subscribe(self, ws: ClientConnection) -> None:
|
||||
args: list[dict[str, str]] = []
|
||||
for inst in self._inst_ids:
|
||||
args.append({"channel": "books5", "instId": inst})
|
||||
args.append({"channel": "mark-price", "instId": inst})
|
||||
if not args:
|
||||
return
|
||||
payload = {"op": "subscribe", "args": args}
|
||||
await ws.send(json.dumps(payload))
|
||||
self._subscribed = {a["instId"] for a in args}
|
||||
logger.info("OKX WS subscribed: %s", sorted(self._subscribed))
|
||||
|
||||
async def _ping_loop(self, ws: ClientConnection) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(self.ping_interval)
|
||||
await ws.send("ping")
|
||||
|
||||
async def _read_loop(self, ws: ClientConnection) -> None:
|
||||
try:
|
||||
async for raw in ws:
|
||||
if raw == "pong":
|
||||
continue
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8", errors="ignore")
|
||||
if raw == "ping":
|
||||
await ws.send("pong")
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
self._handle_message(msg)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
return
|
||||
|
||||
def _handle_message(self, msg: dict[str, Any]) -> None:
|
||||
if msg.get("event") in ("subscribe", "error", "channel-conn-count"):
|
||||
if msg.get("event") == "error":
|
||||
logger.error("OKX WS error: %s", msg)
|
||||
return
|
||||
arg = msg.get("arg") or {}
|
||||
channel = str(arg.get("channel") or "")
|
||||
inst_id = str(arg.get("instId") or "")
|
||||
data = msg.get("data") or []
|
||||
if not inst_id or not data:
|
||||
return
|
||||
row = data[0] if isinstance(data[0], dict) else None
|
||||
if row is None:
|
||||
return
|
||||
|
||||
if channel == "books5":
|
||||
ts = safe_float(row.get("ts"))
|
||||
self.cache.upsert_book(
|
||||
inst_id,
|
||||
bids=_levels(row.get("bids") or []),
|
||||
asks=_levels(row.get("asks") or []),
|
||||
ts_ms=int(ts) if ts is not None else None,
|
||||
)
|
||||
elif channel == "mark-price":
|
||||
ts = safe_float(row.get("ts"))
|
||||
self.cache.set_mark_px(
|
||||
inst_id,
|
||||
safe_float(row.get("markPx")),
|
||||
ts_ms=int(ts) if ts is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _levels(raw: list[Any]) -> list[BookLevel]:
|
||||
out: list[BookLevel] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, (list, tuple)) or len(item) < 2:
|
||||
continue
|
||||
px = safe_float(item[0])
|
||||
sz = safe_float(item[1])
|
||||
if px is None or sz is None or px <= 0 or sz <= 0:
|
||||
continue
|
||||
out.append(BookLevel(px=px, sz=sz))
|
||||
return out
|
||||
Reference in New Issue
Block a user