Initial eth_hedge_sim: P0 market, auth UI, one-click deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-24 16:33:25 +08:00
commit e51f357b48
49 changed files with 4783 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""eth_hedge_sim backend package."""
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from .auth_routes import router as auth_router
from .market import router as market_router
router = APIRouter()
router.include_router(auth_router)
router.include_router(market_router)
+85
View File
@@ -0,0 +1,85 @@
"""简单 HMAC Token 鉴权(无 JWT 依赖)。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from ..config import Settings, get_settings
_bearer = HTTPBearer(auto_error=False)
class LoginRequest(BaseModel):
username: str = Field(min_length=1)
password: str = Field(min_length=1)
class LoginResponse(BaseModel):
token: str
username: str
expires_in: int
env_name: str
mode: str
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def _b64url_decode(s: str) -> bytes:
pad = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def issue_token(username: str, settings: Settings) -> tuple[str, int]:
exp = int(time.time()) + int(settings.auth_token_ttl_sec)
payload = {"u": username, "exp": exp}
raw = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
sig = hmac.new(
settings.auth_secret.encode("utf-8"),
raw.encode("ascii"),
hashlib.sha256,
).hexdigest()
return f"{raw}.{sig}", settings.auth_token_ttl_sec
def verify_token(token: str, settings: Settings) -> str:
try:
raw, sig = token.rsplit(".", 1)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") from e
expect = hmac.new(
settings.auth_secret.encode("utf-8"),
raw.encode("ascii"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expect, sig):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token")
try:
payload = json.loads(_b64url_decode(raw))
except Exception as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") from e
if int(payload.get("exp") or 0) < int(time.time()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token expired")
username = str(payload.get("u") or "")
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token")
return username
def require_user(
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
settings: Annotated[Settings, Depends(get_settings)],
) -> str:
if creds is None or not creds.credentials:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required")
return verify_token(creds.credentials, settings)
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from ..config import Settings, get_settings
from .auth import LoginRequest, LoginResponse, issue_token, require_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=LoginResponse)
async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_settings)]) -> LoginResponse:
user_ok = body.username == settings.auth_username
pass_ok = body.password == settings.auth_password
if not (user_ok and pass_ok):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
token, ttl = issue_token(body.username, settings)
return LoginResponse(
token=token,
username=body.username,
expires_in=ttl,
env_name=settings.env_name,
mode=settings.mode,
)
@router.get("/me")
async def me(username: Annotated[str, Depends(require_user)], settings: Annotated[Settings, Depends(get_settings)]) -> dict:
return {
"username": username,
"env_name": settings.env_name,
"mode": settings.mode,
"sim": settings.is_sim,
}
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from ..market import get_gateway
from .auth import require_user
router = APIRouter(prefix="/api/market", tags=["market"])
@router.get("/snapshot")
async def market_snapshot(_user: Annotated[str, Depends(require_user)]) -> dict:
gw = get_gateway()
snap = gw.snapshot_dict()
if snap.get("pair") is None:
raise HTTPException(status_code=503, detail="market not aligned yet")
return snap
@router.post("/realign")
async def market_realign(_user: Annotated[str, Depends(require_user)]) -> dict:
"""手动重对齐次日到期 ATM 合约(运维/调试用)。"""
gw = get_gateway()
try:
pair = await gw.realign_async()
except Exception as e:
raise HTTPException(status_code=502, detail=str(e)) from e
return {"ok": True, "pair": pair.to_dict() if pair else None, "snapshot": gw.snapshot_dict()}
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(".env", "../.env"),
env_file_encoding="utf-8",
extra="ignore",
)
mode: str = "SIM"
tz: str = "Asia/Shanghai"
env_name: str = "test" # test / prod
api_host: str = "0.0.0.0"
api_port: int = 5155
# Web 登录(仅本机 .env,勿提交真密码)
auth_username: str = "admin"
auth_password: str = "admin123"
auth_secret: str = "change-me-eth-hedge-sim-secret"
auth_token_ttl_sec: int = 60 * 60 * 24 * 7
okx_api_key: str = ""
okx_api_secret: str = ""
okx_api_passphrase: str = ""
okx_rest_base: str = "https://www.okx.com"
okx_ws_public: str = "wss://ws.okx.com:8443/ws/v5/public"
okx_http_proxy: str = ""
perp_inst_id: str = "ETH-USDT-SWAP"
option_inst_family: str = "ETH-USD_UM"
index_inst_id: str = "ETH-USD"
fee_rate: float = 0.0005
initial_equity: float = 100_000.0
max_rounds: int = 3
open_hhmm: str = "16:00"
stop_open_hhmm: str = "08:00"
@property
def is_sim(self) -> bool:
return self.mode.strip().upper() != "LIVE"
@lru_cache
def get_settings() -> Settings:
return Settings()
+1
View File
@@ -0,0 +1 @@
# Placeholder: live OKX trade adapter (P5). Default off.
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .api import router as api_router
from .config import get_settings
from .market import MarketGateway, set_gateway
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logger = logging.getLogger("eth_hedge_sim")
def resolve_frontend_dist() -> Path:
here = Path(__file__).resolve()
# backend/app/main.py -> repo root is parents[2]
repo_root = here.parents[2]
return repo_root / "frontend" / "dist"
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
if not settings.is_sim:
logger.warning("MODE=%s — still read-only market in current phase", settings.mode)
gw = MarketGateway(settings)
set_gateway(gw)
try:
await gw.start()
logger.info("market gateway started (SIM read-only)")
except Exception:
logger.exception("market gateway failed to start")
yield
await gw.stop()
set_gateway(None)
app = FastAPI(
title="eth_hedge_sim",
version="0.2.0",
description="ETH 自动对冲模拟盘",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
@app.get("/health")
async def health() -> dict:
from .market import get_gateway
settings = get_settings()
gw = get_gateway()
snap = gw.snapshot()
return {
"ok": True,
"mode": settings.mode,
"env_name": settings.env_name,
"sim": settings.is_sim,
"market_connected": snap.connected,
"pair": snap.pair.to_dict() if snap.pair else None,
"updated_at_ms": snap.updated_at_ms,
}
_DIST = resolve_frontend_dist()
if (_DIST / "assets").is_dir():
app.mount("/assets", StaticFiles(directory=str(_DIST / "assets")), name="assets")
@app.get("/")
async def index_page():
index = _DIST / "index.html"
if index.exists():
return FileResponse(index)
return {
"ok": True,
"msg": "frontend not built yet; run: cd frontend && npm ci && npm run build",
"health": "/health",
}
@app.get("/app/{full_path:path}")
@app.get("/plan")
@app.get("/trades")
@app.get("/stats")
@app.get("/settings")
@app.get("/login")
async def spa_pages(full_path: str = ""):
index = _DIST / "index.html"
if not index.exists():
raise HTTPException(status_code=404, detail="frontend not built")
return FileResponse(index)
+22
View File
@@ -0,0 +1,22 @@
"""OKX 实盘只读行情网关。"""
from .book_cache import BookCache
from .gateway import MarketGateway, get_gateway, set_gateway
from .instruments import next_session_expiry_ymd, select_option_pair
from .okx_rest import OkxRestClient
from .okx_ws import OkxPublicWs
from .types import MarketSnapshot, OptionPair, Quote
__all__ = [
"BookCache",
"MarketGateway",
"MarketSnapshot",
"OkxPublicWs",
"OkxRestClient",
"OptionPair",
"Quote",
"get_gateway",
"next_session_expiry_ymd",
"select_option_pair",
"set_gateway",
]
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
import threading
import time
from typing import Iterable
from .types import BookLevel, MarketSnapshot, OptionPair, Quote
class BookCache:
"""内存盘口缓存:永续 + Call/Put。线程安全。"""
def __init__(self) -> None:
self._lock = threading.RLock()
self._quotes: dict[str, Quote] = {}
self._index_px: float | None = None
self._pair: OptionPair | None = None
self._connected = False
self._updated_at_ms: int | None = None
def set_connected(self, ok: bool) -> None:
with self._lock:
self._connected = bool(ok)
def set_pair(self, pair: OptionPair | None) -> None:
with self._lock:
self._pair = pair
def set_index_px(self, px: float | None) -> None:
with self._lock:
if px is not None and px > 0:
self._index_px = float(px)
self._touch()
def upsert_book(
self,
inst_id: str,
*,
bids: list[BookLevel],
asks: list[BookLevel],
ts_ms: int | None = None,
) -> None:
with self._lock:
q = self._quotes.get(inst_id) or Quote(inst_id=inst_id)
q.bids = bids
q.asks = asks
q.bid = bids[0].px if bids else None
q.ask = asks[0].px if asks else None
q.bid_sz = bids[0].sz if bids else None
q.ask_sz = asks[0].sz if asks else None
if ts_ms is not None:
q.ts_ms = ts_ms
self._quotes[inst_id] = q
self._touch(ts_ms)
def upsert_top(
self,
inst_id: str,
*,
bid: float | None,
ask: float | None,
bid_sz: float | None = None,
ask_sz: float | None = None,
ts_ms: int | None = None,
) -> None:
with self._lock:
q = self._quotes.get(inst_id) or Quote(inst_id=inst_id)
if bid is not None:
q.bid = bid
if ask is not None:
q.ask = ask
if bid_sz is not None:
q.bid_sz = bid_sz
if ask_sz is not None:
q.ask_sz = ask_sz
if ts_ms is not None:
q.ts_ms = ts_ms
# 同步一层盘口,便于 snapshot 展示
if bid is not None and bid_sz is not None:
q.bids = [BookLevel(px=bid, sz=bid_sz)] + q.bids[1:]
if ask is not None and ask_sz is not None:
q.asks = [BookLevel(px=ask, sz=ask_sz)] + q.asks[1:]
self._quotes[inst_id] = q
self._touch(ts_ms)
def set_mark_px(self, inst_id: str, mark_px: float | None, ts_ms: int | None = None) -> None:
with self._lock:
if mark_px is None or mark_px <= 0:
return
q = self._quotes.get(inst_id) or Quote(inst_id=inst_id)
q.mark_px = float(mark_px)
if ts_ms is not None:
q.ts_ms = ts_ms
self._quotes[inst_id] = q
self._touch(ts_ms)
def get(self, inst_id: str) -> Quote | None:
with self._lock:
return self._quotes.get(inst_id)
def drop_except(self, keep: Iterable[str]) -> None:
keep_set = set(keep)
with self._lock:
for k in list(self._quotes):
if k not in keep_set:
del self._quotes[k]
def snapshot(self, perp_inst_id: str) -> MarketSnapshot:
with self._lock:
pair = self._pair
call = self._quotes.get(pair.call_inst_id) if pair else None
put = self._quotes.get(pair.put_inst_id) if pair else None
return MarketSnapshot(
perp=self._quotes.get(perp_inst_id),
call=call,
put=put,
index_px=self._index_px,
pair=pair,
connected=self._connected,
updated_at_ms=self._updated_at_ms,
)
def _touch(self, ts_ms: int | None = None) -> None:
self._updated_at_ms = int(ts_ms) if ts_ms is not None else int(time.time() * 1000)
+150
View File
@@ -0,0 +1,150 @@
"""行情网关:REST 对齐合约 + WS 推送盘口。"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from ..config import Settings, get_settings
from .book_cache import BookCache
from .instruments import next_session_expiry_ymd, select_option_pair
from .okx_rest import OkxRestClient
from .okx_ws import OkxPublicWs
from .types import MarketSnapshot, OptionPair
logger = logging.getLogger(__name__)
class MarketGateway:
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._pair: OptionPair | None = None
self._refresh_task: asyncio.Task[None] | None = None
self._started = False
@property
def pair(self) -> OptionPair | None:
return self._pair
async def start(self) -> None:
if self._started:
return
self._started = True
await asyncio.to_thread(self.align_instruments)
await self.ws.start()
self._refresh_task = asyncio.create_task(self._refresh_loop(), name="market-align")
async def stop(self) -> None:
self._started = False
if self._refresh_task:
self._refresh_task.cancel()
try:
await self._refresh_task
except asyncio.CancelledError:
pass
self._refresh_task = None
await self.ws.stop()
self.rest.close()
def align_instruments(self) -> OptionPair | None:
"""同步:拉期权列表,选次日到期 ATM Call/Put,REST 预热盘口,切换 WS 订阅。"""
s = self.settings
idx = self.rest.fetch_index_ticker(s.index_inst_id)
mark = self.rest.fetch_mark_price(s.perp_inst_id) or idx
if mark is None or mark <= 0:
raise RuntimeError("无法获取 ETH 标记/指数价格,无法选 ATM")
instruments = self.rest.fetch_option_instruments(s.option_inst_family)
ymd = next_session_expiry_ymd()
pair = select_option_pair(instruments, mark_px=float(mark), expiry_ymd=ymd)
if pair is None:
raise RuntimeError(f"未找到到期 {ymd} 的 ATM Call/Put 合约 pair (family={s.option_inst_family})")
self._pair = pair
self.cache.set_pair(pair)
self.cache.set_index_px(idx)
# REST 预热:永续 + Call + Put
for inst in (s.perp_inst_id, pair.call_inst_id, pair.put_inst_id):
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 = {s.perp_inst_id, pair.call_inst_id, pair.put_inst_id}
self.cache.drop_except(keep)
self.ws.set_instruments([s.perp_inst_id, pair.call_inst_id, pair.put_inst_id])
logger.info(
"aligned pair expiry=%s strike=%s call=%s put=%s mark=%.2f",
pair.expiry_ymd,
pair.strike,
pair.call_inst_id,
pair.put_inst_id,
mark,
)
return pair
async def realign_async(self) -> OptionPair | None:
old = self._pair
pair = await asyncio.to_thread(self.align_instruments)
if old is None or (
pair
and (
pair.call_inst_id != old.call_inst_id
or pair.put_inst_id != old.put_inst_id
)
):
await self.ws.resubscribe(
[
self.settings.perp_inst_id,
pair.call_inst_id,
pair.put_inst_id,
]
)
return pair
def snapshot(self) -> MarketSnapshot:
return self.cache.snapshot(self.settings.perp_inst_id)
def snapshot_dict(self) -> dict[str, Any]:
return self.snapshot().to_dict()
async def _refresh_loop(self) -> None:
"""周期性刷新指数价;跨日到期切换时重对齐。"""
while True:
await asyncio.sleep(30)
try:
idx = await asyncio.to_thread(
self.rest.fetch_index_ticker, self.settings.index_inst_id
)
self.cache.set_index_px(idx)
want = next_session_expiry_ymd()
if self._pair and self._pair.expiry_ymd != want:
logger.info("expiry rollover %s -> %s", self._pair.expiry_ymd, want)
await self.realign_async()
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning("market refresh failed: %s", e)
# 进程级单例(FastAPI lifespan 注入)
_gateway: MarketGateway | None = None
def get_gateway() -> MarketGateway:
global _gateway
if _gateway is None:
_gateway = MarketGateway()
return _gateway
def set_gateway(gw: MarketGateway | None) -> None:
global _gateway
_gateway = gw
+119
View File
@@ -0,0 +1,119 @@
"""合约选择:次日 16:00(上海)到期 + ATM 行权价(暂定默认,待拍板可改)。"""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from typing import Any
from zoneinfo import ZoneInfo
from .types import OptionPair
_SH = ZoneInfo("Asia/Shanghai")
_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 next_session_expiry_ymd(now: datetime | None = None) -> str:
"""
业务约定:开仓选「次日 16:00」到期。
- 上海时间 >= 当日 16:00:目标到期日 = 次日
- 上海时间 < 当日 16:00:目标到期日 = 当日(当日 16:00 到期仍可用作盘口对齐/预热)
正式开仓窗从当日 16:00 起,届时「次日」即日历次日。
"""
now_sh = (now or datetime.now(tz=_SH)).astimezone(_SH)
open_today = now_sh.replace(hour=16, minute=0, second=0, microsecond=0)
if now_sh >= open_today:
target = now_sh.date() + timedelta(days=1)
else:
target = now_sh.date()
return target.strftime("%y%m%d")
def pick_atm_strike(strikes: list[float], mark_px: float) -> float | None:
if not strikes or mark_px <= 0:
return None
return min(strikes, key=lambda s: (abs(s - mark_px), s))
def select_option_pair(
instruments: list[dict[str, Any]],
*,
mark_px: float,
expiry_ymd: str | None = None,
now: datetime | None = None,
) -> OptionPair | None:
"""
从 live 合约列表中选出:目标到期日 + ATM 同行权价 Call/Put。
行权价规则暂定 ATM(最接近标记/指数价);待拍板后可替换。
"""
ymd = expiry_ymd or next_session_expiry_ymd(now)
by_strike: dict[float, dict[str, str]] = {}
for row in instruments:
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 y != ymd or stk is None or opt not in ("C", "P"):
continue
by_strike.setdefault(float(stk), {})[opt] = inst_id
complete = {s: v for s, v in by_strike.items() if "C" in v and "P" in v}
if not complete:
return None
atm = pick_atm_strike(list(complete.keys()), mark_px)
if atm is None:
return None
legs = complete[atm]
return OptionPair(
expiry_ymd=ymd,
expiry_ms=expiry_ms_from_ymd(ymd),
strike=atm,
call_inst_id=legs["C"],
put_inst_id=legs["P"],
)
+99
View File
@@ -0,0 +1,99 @@
"""OKX REST 只读行情。不调用任何交易类接口。"""
from __future__ import annotations
from typing import Any
import httpx
from .instruments import safe_float
from .types import BookLevel
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
+207
View File
@@ -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 .instruments import safe_float
from .types import BookLevel
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
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class BookLevel:
px: float
sz: float # OKX 张数 / 合约张数口径
@dataclass(slots=True)
class Quote:
inst_id: str
bid: float | None = None
ask: float | None = None
bid_sz: float | None = None
ask_sz: float | None = None
mark_px: float | None = None
ts_ms: int | None = None
bids: list[BookLevel] = field(default_factory=list)
asks: list[BookLevel] = field(default_factory=list)
def to_dict(self, *, depth: int = 5) -> dict[str, Any]:
return {
"inst_id": self.inst_id,
"bid": self.bid,
"ask": self.ask,
"bid_sz": self.bid_sz,
"ask_sz": self.ask_sz,
"mark_px": self.mark_px,
"ts_ms": self.ts_ms,
"bids": [{"px": x.px, "sz": x.sz} for x in self.bids[:depth]],
"asks": [{"px": x.px, "sz": x.sz} for x in self.asks[:depth]],
}
@dataclass(slots=True)
class OptionPair:
expiry_ymd: str # YYMMDD
expiry_ms: int
strike: float
call_inst_id: str
put_inst_id: str
def to_dict(self) -> dict[str, Any]:
return {
"expiry_ymd": self.expiry_ymd,
"expiry_ms": self.expiry_ms,
"strike": self.strike,
"call_inst_id": self.call_inst_id,
"put_inst_id": self.put_inst_id,
}
@dataclass(slots=True)
class MarketSnapshot:
perp: Quote | None
call: Quote | None
put: Quote | None
index_px: float | None
pair: OptionPair | None
connected: bool
updated_at_ms: int | None
def to_dict(self) -> dict[str, Any]:
return {
"connected": self.connected,
"updated_at_ms": self.updated_at_ms,
"index_px": self.index_px,
"pair": self.pair.to_dict() if self.pair else None,
"perp": self.perp.to_dict() if self.perp else None,
"call": self.call.to_dict() if self.call else None,
"put": self.put.to_dict() if self.put else None,
"ask_compare": {
"call_ask": self.call.ask if self.call else None,
"put_ask": self.put.ask if self.put else None,
"bias": _ask_bias(self.call, self.put),
},
}
def _ask_bias(call: Quote | None, put: Quote | None) -> str:
"""卖一比价仅用于选向展示;相等则 wait。"""
ca = call.ask if call else None
pa = put.ask if put else None
if ca is None or pa is None:
return "unknown"
if ca > pa:
return "call_ask_gt_put" # 永续多 + 期权空(腿待拍板)
if ca < pa:
return "put_ask_gt_call" # 永续空 + 期权多(腿待拍板)
return "equal"
+1
View File
@@ -0,0 +1 @@
# Placeholder: DB models (P1).
+1
View File
@@ -0,0 +1 @@
# Placeholder: application services (P1+).
+1
View File
@@ -0,0 +1 @@
# Placeholder packages for later phases (P1P5).
+1
View File
@@ -0,0 +1 @@
# Placeholder: strategy state machine (P2).
+1
View File
@@ -0,0 +1 @@
# Placeholder: frontend push WS (P3).
View File
+8
View File
@@ -0,0 +1,8 @@
from __future__ import annotations
import sys
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
if str(BACKEND) not in sys.path:
sys.path.insert(0, str(BACKEND))
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
from app.market.instruments import (
next_session_expiry_ymd,
parse_option_inst_id,
pick_atm_strike,
select_option_pair,
)
_SH = ZoneInfo("Asia/Shanghai")
def test_parse_option_inst_id() -> None:
y, s, o = parse_option_inst_id("ETH-USD_UM-260725-3500-C")
assert y == "260725"
assert s == 3500.0
assert o == "C"
def test_pick_atm_strike() -> None:
assert pick_atm_strike([3400, 3500, 3600], 3510) == 3500
def test_next_session_expiry_before_open() -> None:
now = datetime(2026, 7, 24, 15, 0, tzinfo=_SH)
assert next_session_expiry_ymd(now) == "260724"
def test_next_session_expiry_after_open() -> None:
now = datetime(2026, 7, 24, 16, 0, tzinfo=_SH)
assert next_session_expiry_ymd(now) == "260725"
def test_select_option_pair_atm() -> None:
rows = [
{"instId": "ETH-USD_UM-260725-3490-C", "state": "live"},
{"instId": "ETH-USD_UM-260725-3490-P", "state": "live"},
{"instId": "ETH-USD_UM-260725-3500-C", "state": "live"},
{"instId": "ETH-USD_UM-260725-3500-P", "state": "live"},
{"instId": "ETH-USD_UM-260726-3500-C", "state": "live"},
{"instId": "ETH-USD_UM-260726-3500-P", "state": "live"},
]
pair = select_option_pair(rows, mark_px=3502, expiry_ymd="260725")
assert pair is not None
assert pair.strike == 3500
assert pair.call_inst_id.endswith("-3500-C")
assert pair.put_inst_id.endswith("-3500-P")