Add Binance SIM market adapter and exchange switch in settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-25 12:02:36 +08:00
parent 5dcec0fde0
commit 78fd046fb6
25 changed files with 879 additions and 45 deletions
+8 -1
View File
@@ -4,7 +4,7 @@
MODE=SIM
ENV_NAME=test
TZ=Asia/Shanghai
# 交易所模块:okx(已接入)| binance占位
# 交易所模块:okx | binanceSIM 公共行情;设置页可切换,DB 优先
EXCHANGE=okx
# HTTP(前后端同端口,默认 5155)
@@ -26,6 +26,13 @@ OKX_WS_PUBLIC=wss://ws.okx.com:8443/ws/v5/public
# 云上一般直连留空;本机受限时再填代理
OKX_HTTP_PROXY=
# 币安公共行情(SIM
BINANCE_FAPI_BASE=https://fapi.binance.com
BINANCE_EAPI_BASE=https://eapi.binance.com
BINANCE_FUTURES_WS=wss://fstream.binance.com/stream
BINANCE_OPTIONS_WS=wss://nbstream.binance.com/eoptions/stream
BINANCE_HTTP_PROXY=
PERP_INST_ID=ETH-USDT-SWAP
OPTION_INST_FAMILY=ETH-USD_UM
INDEX_INST_ID=ETH-USD
+1 -2
View File
@@ -14,8 +14,7 @@ router = APIRouter(prefix="/api/market", tags=["market"])
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")
# 切换交易所后短时可能尚未对齐 ATM;仍返回结构便于前端展示交易所
return snap
+40
View File
@@ -6,6 +6,12 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from ..config import get_settings
from ..exchange.runtime import (
load_runtime_settings,
normalize_exchange_name,
persist_exchange_choice,
reload_market_session,
)
from ..models.db import get_db
from ..sim.ledger import Ledger
from ..sim.matcher import Matcher
@@ -46,6 +52,7 @@ class StrategySettingsBody(BaseModel):
close_bid_mark_max_pct: float | None = Field(default=None, ge=1, le=100)
perp_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
option_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
exchange: str | None = Field(default=None, pattern="^(okx|binance|bn)$")
def _as_bool(raw: str | None, default: bool) -> bool:
@@ -57,6 +64,7 @@ def _as_bool(raw: str | None, default: bool) -> bool:
def _read_settings() -> dict:
db = get_db()
s = get_settings()
rt = load_runtime_settings()
mode = str(db.get_setting("exit_mode", s.exit_mode) or s.exit_mode)
if mode not in ("fixed_usdt", "premium_multiple"):
mode = "fixed_usdt"
@@ -102,6 +110,10 @@ def _read_settings() -> dict:
"option_qty_eth": float(
db.get_setting("option_qty_eth", str(s.option_qty_eth)) or s.option_qty_eth
),
"exchange": rt.exchange,
"perp_inst_id": rt.perp_inst_id,
"option_inst_family": rt.option_inst_family,
"index_inst_id": rt.index_inst_id,
"ledger": Ledger(db).snapshot(),
}
@@ -120,6 +132,21 @@ async def put_strategy_settings(
s = get_settings()
data = body.model_dump(exclude_none=True)
equity_to_apply: float | None = None
switch_to: str | None = None
if "exchange" in data:
new_ex = normalize_exchange_name(str(data.pop("exchange")))
old_ex = normalize_exchange_name(
db.get_setting("exchange", s.exchange) or s.exchange
)
if new_ex != old_ex:
if Matcher(db).has_open_position():
raise HTTPException(
status_code=409,
detail="有未平仓,无法切换交易所;请先平仓后再改",
)
switch_to = new_ex
if "initial_equity" in data:
new_eq = float(data["initial_equity"])
old_eq = float(
@@ -132,12 +159,25 @@ async def put_strategy_settings(
detail="有未平仓,无法重置模拟资金;请先平仓后再改",
)
equity_to_apply = new_eq
for k, v in data.items():
if k in KEYS:
db.set_setting(k, str(v))
if equity_to_apply is not None:
Ledger(db).reset_equity(
equity_to_apply,
note=f"设置模拟资金={equity_to_apply:.2f}",
)
if switch_to is not None:
rt = persist_exchange_choice(switch_to)
try:
await reload_market_session(rt)
except Exception as e:
raise HTTPException(
status_code=502,
detail=f"交易所已切换为 {switch_to},但行情重连失败: {e}",
) from e
return _read_settings()
+25 -1
View File
@@ -33,9 +33,17 @@ class Settings(BaseSettings):
okx_ws_public: str = "wss://ws.okx.com:8443/ws/v5/public"
okx_http_proxy: str = ""
# 币安公共行情(SIM 只读)
binance_fapi_base: str = "https://fapi.binance.com"
binance_eapi_base: str = "https://eapi.binance.com"
binance_futures_ws: str = "wss://fstream.binance.com/stream"
binance_options_ws: str = "wss://nbstream.binance.com/eoptions/stream"
binance_http_proxy: str = ""
perp_inst_id: str = "ETH-USDT-SWAP"
option_inst_family: str = "ETH-USD_UM"
index_inst_id: str = "ETH-USD"
option_ct_mult_default: float = 0.01
fee_rate: float = 0.0005
initial_equity: float = 10_000.0 # SIM 模拟初始资金(USDT),设置页可改
@@ -55,7 +63,6 @@ class Settings(BaseSettings):
close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差%
perp_qty_eth: float = 1.0
option_qty_eth: float = 2.0
option_ct_mult_default: float = 0.01
db_path: str = "" # empty -> backend/data/hedge.db
@property
@@ -63,6 +70,23 @@ class Settings(BaseSettings):
return self.mode.strip().upper() != "LIVE"
# 切换交易所时的合约默认
EXCHANGE_MARKET_DEFAULTS: dict[str, dict[str, str | float]] = {
"okx": {
"perp_inst_id": "ETH-USDT-SWAP",
"option_inst_family": "ETH-USD_UM",
"index_inst_id": "ETH-USD",
"option_ct_mult_default": 0.01,
},
"binance": {
"perp_inst_id": "ETHUSDT",
"option_inst_family": "ETHUSDT",
"index_inst_id": "ETHUSDT",
"option_ct_mult_default": 1.0,
},
}
@lru_cache
def get_settings() -> Settings:
return Settings()
+1 -1
View File
@@ -1,4 +1,4 @@
"""交易所模块:OKX 已接入,币安占位。策略不直接依赖具体交易所。"""
"""交易所模块:OKX / 币安公共行情。策略不直接依赖具体交易所。"""
from .factory import build_exchange, get_exchange, set_exchange
from .protocol import ExchangeMarket
+78 -16
View File
@@ -1,11 +1,18 @@
"""币安交易所适配器占位:后期接入,接口与 OKX 对齐"""
"""币安交易所适配器:USDT 永续 + 欧洲期权公共行情(SIM 只读)"""
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 safe_float
from .rest import BinanceRestClient
from .ws import BinancePublicWs
logger = logging.getLogger(__name__)
class BinanceExchange:
@@ -13,50 +20,105 @@ class BinanceExchange:
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or get_settings()
self.cache = BookCache()
proxy = (
self.settings.binance_http_proxy
or self.settings.okx_http_proxy
or None
)
self.rest = BinanceRestClient(
fapi_base=self.settings.binance_fapi_base,
eapi_base=self.settings.binance_eapi_base,
proxy=proxy,
)
self.ws = BinancePublicWs(
futures_ws_base=self.settings.binance_futures_ws,
options_ws_base=self.settings.binance_options_ws,
cache=self.cache,
proxy=proxy,
)
self._started = False
self._ct_cache: dict[str, float] = {}
async def start(self) -> None:
raise NotImplementedError("币安交易所模块尚未接入,请配置 EXCHANGE=okx")
if self._started:
return
self._started = True
await self.ws.start()
logger.info("Binance exchange started")
async def stop(self) -> None:
return
self._started = False
await self.ws.stop()
self.rest.close()
logger.info("Binance exchange stopped")
def list_option_contracts(self, family: str) -> list[dict[str, Any]]:
raise NotImplementedError("BinanceExchange.list_option_contracts")
contracts = self.rest.list_option_contracts(family)
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:
raise NotImplementedError("BinanceExchange.fetch_index")
return self.rest.fetch_index(index_id)
def fetch_mark(self, inst_id: str) -> float | None:
raise NotImplementedError("BinanceExchange.fetch_mark")
return self.rest.fetch_mark(inst_id)
def fetch_book(
self, inst_id: str, depth: int = 5
) -> tuple[list[BookLevel], list[BookLevel], int | None]:
raise NotImplementedError("BinanceExchange.fetch_book")
return self.rest.fetch_books(inst_id, sz=depth)
def get_ct_mult(self, option_inst_id: str, family: str, default: float) -> float:
return float(default)
if option_inst_id in self._ct_cache:
return self._ct_cache[option_inst_id]
# 币安 ETH 期权 unit 常见为 1
return float(default if default > 0 else 1.0)
def set_pair(self, pair: OptionPair | None) -> None:
raise NotImplementedError("BinanceExchange.set_pair")
self.cache.set_pair(pair)
def warm_and_subscribe(self, inst_ids: Sequence[str]) -> None:
raise NotImplementedError("BinanceExchange.warm_and_subscribe")
ids = [i for i in inst_ids if i]
for inst in ids:
try:
bids, asks, ts = self.rest.fetch_books(inst, sz=5)
self.cache.upsert_book(inst, bids=bids, asks=asks, ts_ms=ts)
except Exception as e:
logger.warning("binance warm book %s failed: %s", inst, e)
try:
mp = self.rest.fetch_mark(inst)
if mp:
self.cache.set_mark_px(inst, mp)
except Exception:
pass
# 指数
try:
idx = self.rest.fetch_index(self.settings.index_inst_id)
if idx:
self.cache.set_index_px(idx)
except Exception as e:
logger.warning("binance index failed: %s", e)
keep = set(ids)
self.cache.drop_except(keep)
self.ws.set_instruments(ids)
async def resubscribe(self, inst_ids: Sequence[str]) -> None:
raise NotImplementedError("BinanceExchange.resubscribe")
await self.ws.resubscribe([i for i in inst_ids if i])
def quote(self, inst_id: str) -> Quote | None:
return None
return self.cache.get(inst_id)
def snapshot(self, perp_inst_id: str) -> MarketSnapshot:
raise NotImplementedError("BinanceExchange.snapshot")
return self.cache.snapshot(perp_inst_id)
def snapshot_dict(self, perp_inst_id: str) -> dict[str, Any]:
raise NotImplementedError("BinanceExchange.snapshot_dict")
return self.snapshot(perp_inst_id).to_dict()
def set_index_px(self, px: float | None) -> None:
return
self.cache.set_index_px(px)
def set_mark_px(self, inst_id: str, mark_px: float | None) -> None:
return
self.cache.set_mark_px(inst_id, mark_px)
+97
View File
@@ -0,0 +1,97 @@
"""币安期权 / 永续符号解析 → 中性合约行。"""
from __future__ import annotations
import re
from typing import Any
from ..expiry import expiry_ms_from_ymd, ymd_from_expiry_ms
_OPT_RE = re.compile(
r"^(?P<under>[A-Z0-9]+)-(?P<ymd>\d{6})-(?P<strike>\d+(?:\.\d+)?)-(?P<side>[CP])$",
re.IGNORECASE,
)
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_symbol(symbol: str) -> tuple[str | None, float | None, str | None]:
"""ETH-250726-1860-C → (YYMMDD, strike, C|P)."""
m = _OPT_RE.match((symbol or "").strip())
if not m:
return None, None, None
ymd = m.group("ymd")
strike = safe_float(m.group("strike"))
side = m.group("side").upper()
return ymd, strike, side
def is_option_symbol(symbol: str) -> bool:
y, s, o = parse_option_symbol(symbol)
return y is not None and s is not None and o in ("C", "P")
def rows_to_option_contracts(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
归一化:
{inst_id, expiry_ymd, expiry_ms, strike, side, ct_mult}
"""
out: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
continue
status = str(row.get("status") or "TRADING").upper()
if status and status not in ("TRADING", "LIVE", ""):
continue
inst_id = str(row.get("symbol") or row.get("inst_id") or "")
y, stk, opt = parse_option_symbol(inst_id)
exp_ms = None
raw_exp = row.get("expiryDate") or row.get("expiration") or row.get("expiry_ms")
if raw_exp is not None:
try:
exp_ms = int(float(raw_exp))
if exp_ms < 10_000_000_000: # seconds
exp_ms *= 1000
except (TypeError, ValueError):
exp_ms = None
if y is None and exp_ms is not None:
y = ymd_from_expiry_ms(exp_ms)
if stk is None:
stk = safe_float(row.get("strikePrice") or row.get("strike"))
if opt is None:
side_raw = str(row.get("side") or row.get("optionSide") or "").upper()
if side_raw in ("CALL", "C"):
opt = "C"
elif side_raw in ("PUT", "P"):
opt = "P"
if not inst_id or not y or stk is None or opt not in ("C", "P"):
continue
if exp_ms is None:
try:
exp_ms = expiry_ms_from_ymd(y)
except ValueError:
continue
unit = safe_float(row.get("unit") or row.get("ct_mult"))
out.append(
{
"inst_id": inst_id,
"expiry_ymd": y,
"expiry_ms": int(exp_ms),
"strike": float(stk),
"side": opt,
"ct_mult": float(unit) if unit and unit > 0 else 1.0,
}
)
return out
+155
View File
@@ -0,0 +1,155 @@
"""币安只读 RESTUSDT 永续 (fapi) + 欧洲期权 (eapi)。"""
from __future__ import annotations
from typing import Any
import httpx
from ..types import BookLevel
from .parse import rows_to_option_contracts, safe_float
class BinanceRestClient:
def __init__(
self,
*,
fapi_base: str = "https://fapi.binance.com",
eapi_base: str = "https://eapi.binance.com",
timeout: float = 15.0,
proxy: str | None = None,
) -> None:
self.fapi_base = fapi_base.rstrip("/")
self.eapi_base = eapi_base.rstrip("/")
self.proxy = (proxy or "").strip() or None
headers = {"Accept": "application/json", "User-Agent": "eth-hedge-sim/0.3"}
self._fapi = httpx.Client(
base_url=self.fapi_base,
timeout=timeout,
proxy=self.proxy,
headers=headers,
trust_env=False,
)
self._eapi = httpx.Client(
base_url=self.eapi_base,
timeout=timeout,
proxy=self.proxy,
headers=headers,
trust_env=False,
)
self._exchange_info: dict[str, Any] | None = None
def close(self) -> None:
self._fapi.close()
self._eapi.close()
def _get_json(self, client: httpx.Client, path: str, params: dict[str, Any] | None = None) -> Any:
r = client.get(path, params=params or {})
r.raise_for_status()
return r.json()
def fetch_option_exchange_info(self) -> dict[str, Any]:
if self._exchange_info is None:
body = self._get_json(self._eapi, "/eapi/v1/exchangeInfo")
self._exchange_info = body if isinstance(body, dict) else {}
return self._exchange_info
def fetch_option_instruments(self, underlying: str) -> list[dict[str, Any]]:
"""underlying 如 ETH / ETHUSDT。"""
info = self.fetch_option_exchange_info()
rows = info.get("optionSymbols") or info.get("symbols") or []
want = (underlying or "ETHUSDT").strip().upper()
eth_mode = want in ("ETH", "ETHUSDT") or want.startswith("ETH")
out: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
continue
u = str(row.get("underlying") or row.get("underlyingAsset") or "").upper()
sym = str(row.get("symbol") or "").upper()
if eth_mode:
if sym.startswith("ETH-") or u.startswith("ETH"):
out.append(row)
continue
base = want.replace("USDT", "") if want.endswith("USDT") else want
if u == want or u == base or sym.startswith(f"{base}-"):
out.append(row)
return out
def list_option_contracts(self, family: str) -> list[dict[str, Any]]:
return rows_to_option_contracts(self.fetch_option_instruments(family))
def fetch_index(self, underlying: str) -> float | None:
"""期权指数:underlying=ETHUSDT。"""
u = (underlying or "ETHUSDT").strip().upper()
if not u.endswith("USDT") and u.isalpha():
u = f"{u}USDT"
try:
body = self._get_json(self._eapi, "/eapi/v1/index", {"underlying": u})
if isinstance(body, dict):
return safe_float(body.get("indexPrice") or body.get("price"))
except Exception:
pass
# 回退永续标记
return self.fetch_mark_perp(u if u.endswith("USDT") else "ETHUSDT")
def fetch_mark_perp(self, symbol: str) -> float | None:
body = self._get_json(
self._fapi, "/fapi/v1/premiumIndex", {"symbol": (symbol or "ETHUSDT").upper()}
)
if isinstance(body, dict):
return safe_float(body.get("markPrice")) or safe_float(body.get("indexPrice"))
return None
def fetch_mark_option(self, symbol: str) -> float | None:
body = self._get_json(self._eapi, "/eapi/v1/mark", {"symbol": symbol})
if isinstance(body, list) and body:
return safe_float(body[0].get("markPrice"))
if isinstance(body, dict):
return safe_float(body.get("markPrice"))
return None
def fetch_mark(self, inst_id: str) -> float | None:
from .parse import is_option_symbol
if is_option_symbol(inst_id):
return self.fetch_mark_option(inst_id)
return self.fetch_mark_perp(inst_id)
def fetch_books(
self, inst_id: str, sz: int = 5
) -> tuple[list[BookLevel], list[BookLevel], int | None]:
from .parse import is_option_symbol
limit = max(5, min(int(sz), 100))
if is_option_symbol(inst_id):
body = self._get_json(
self._eapi, "/eapi/v1/depth", {"symbol": inst_id, "limit": limit}
)
else:
body = self._get_json(
self._fapi,
"/fapi/v1/depth",
{"symbol": inst_id.upper(), "limit": min(limit, 20)},
)
if not isinstance(body, dict):
return [], [], None
ts = safe_float(body.get("T") or body.get("E") or body.get("time"))
ts_ms = int(ts) if ts is not None else None
return (
_levels(body.get("bids") or []),
_levels(body.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
+204
View File
@@ -0,0 +1,204 @@
"""币安公共 WebSocketUSDT 永续 bookTicker + 期权 bookTicker。只读。"""
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 .parse import is_option_symbol, safe_float
logger = logging.getLogger(__name__)
class BinancePublicWs:
def __init__(
self,
*,
futures_ws_base: str,
options_ws_base: str,
cache: BookCache,
proxy: str | None = None,
ping_interval: float = 20.0,
) -> None:
self.futures_ws_base = futures_ws_base.rstrip("/")
self.options_ws_base = options_ws_base.rstrip("/")
self.cache = cache
self.proxy = (proxy or "").strip() or None
self.ping_interval = ping_interval
self._inst_ids: list[str] = []
self._tasks: list[asyncio.Task[None]] = []
self._stop = asyncio.Event()
def set_instruments(self, inst_ids: list[str]) -> None:
self._inst_ids = [i for i in inst_ids if i]
def _split(self) -> tuple[list[str], list[str]]:
perps: list[str] = []
opts: list[str] = []
for i in self._inst_ids:
if is_option_symbol(i):
opts.append(i)
else:
perps.append(i.upper())
return perps, opts
async def start(self) -> None:
if self._tasks and any(not t.done() for t in self._tasks):
return
self._stop.clear()
await self._spawn()
async def stop(self) -> None:
self._stop.set()
for t in self._tasks:
t.cancel()
for t in self._tasks:
try:
await t
except asyncio.CancelledError:
pass
self._tasks = []
self.cache.set_connected(False)
async def resubscribe(self, inst_ids: list[str]) -> None:
self.set_instruments(inst_ids)
await self.stop()
self._stop.clear()
await self._spawn()
async def _spawn(self) -> None:
perps, opts = self._split()
self._tasks = []
if perps:
url = self._combined_url(self.futures_ws_base, [f"{p.lower()}@bookTicker" for p in perps])
self._tasks.append(
asyncio.create_task(self._run_forever(url, kind="futures"), name="bn-fapi-ws")
)
if opts:
streams = [f"{s}@bookTicker" for s in opts]
url = self._combined_url(self.options_ws_base, streams)
self._tasks.append(
asyncio.create_task(self._run_forever(url, kind="options"), name="bn-eapi-ws")
)
if not self._tasks:
self.cache.set_connected(False)
@staticmethod
def _combined_url(base: str, streams: list[str]) -> str:
# base like wss://fstream.binance.com/stream or .../eoptions/stream
if "/stream" in base:
root = base
else:
root = base.rstrip("/") + "/stream"
return root + "?streams=" + "/".join(streams)
async def _open_connection(self, url: str) -> ClientConnection:
if not self.proxy:
return await websockets.connect(
url,
ping_interval=None,
max_size=2**22,
open_timeout=20,
)
from python_socks.async_.asyncio import Proxy
parsed = urlparse(url)
host = parsed.hostname or "fstream.binance.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(
url,
sock=sock,
server_hostname=host,
ping_interval=None,
max_size=2**22,
open_timeout=20,
)
async def _run_forever(self, url: str, *, kind: str) -> None:
backoff = 1.0
while not self._stop.is_set():
try:
async with await self._open_connection(url) as ws:
self.cache.set_connected(True)
backoff = 1.0
logger.info("Binance %s WS connected: %s", kind, url[:120])
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("Binance %s WS disconnected: %s", kind, 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 _ping_loop(self, ws: ClientConnection) -> None:
while True:
await asyncio.sleep(self.ping_interval)
try:
await ws.ping()
except Exception:
return
async def _read_loop(self, ws: ClientConnection) -> None:
try:
async for raw in ws:
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="ignore")
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
data = msg.get("data") if isinstance(msg, dict) and "stream" in msg else msg
if isinstance(data, dict):
self._handle_event(data)
except websockets.exceptions.ConnectionClosed:
return
def _handle_event(self, data: dict[str, Any]) -> None:
et = str(data.get("e") or "")
sym = str(data.get("s") or "")
if not sym:
return
ts = safe_float(data.get("E") or data.get("T"))
ts_ms = int(ts) if ts is not None else None
if et in ("bookTicker", "") or ("b" in data and "a" in data and "s" in data):
bid = safe_float(data.get("b"))
ask = safe_float(data.get("a"))
bid_sz = safe_float(data.get("B"))
ask_sz = safe_float(data.get("A"))
if bid is not None or ask is not None:
self.cache.upsert_top(
sym,
bid=bid,
ask=ask,
bid_sz=bid_sz,
ask_sz=ask_sz,
ts_ms=ts_ms,
)
# 永续可用中间价近似 mark
if not is_option_symbol(sym) and bid and ask:
self.cache.set_mark_px(sym, (bid + ask) / 2.0, ts_ms=ts_ms)
+25
View File
@@ -0,0 +1,25 @@
"""交易所无关的到期时刻工具。
OKX / 币安欧洲期权惯例:到期日当日 08:00 UTC(上海 16:00)。
若合约元数据带有 expiry_ms,优先使用元数据。
"""
from __future__ import annotations
from datetime import datetime, timezone
def expiry_ms_from_ymd(ymd: str) -> int:
"""YYMMDD → 到期毫秒时间戳(UTC 08:00)。"""
ymd = (ymd or "").strip()
if len(ymd) != 6 or not ymd.isdigit():
raise ValueError(f"invalid expiry ymd: {ymd!r}")
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 ymd_from_expiry_ms(ms: int) -> str:
"""到期毫秒 → YYMMDD(按 UTC 日历日)。"""
dt = datetime.fromtimestamp(int(ms) / 1000.0, tz=timezone.utc)
return dt.strftime("%y%m%d")
+6 -4
View File
@@ -2,20 +2,22 @@
from __future__ import annotations
from ..config import Settings, get_settings
from ..config import Settings
from .protocol import ExchangeMarket
_exchange: ExchangeMarket | None = None
def build_exchange(settings: Settings | None = None) -> ExchangeMarket:
s = settings or get_settings()
name = (s.exchange or "okx").strip().lower()
from .runtime import load_runtime_settings, normalize_exchange_name
s = settings or load_runtime_settings()
name = normalize_exchange_name(s.exchange)
if name == "okx":
from .okx.adapter import OkxExchange
return OkxExchange(s)
if name in ("binance", "bn"):
if name == "binance":
from .binance.adapter import BinanceExchange
return BinanceExchange(s)
+14 -8
View File
@@ -6,8 +6,17 @@ import re
from datetime import datetime, timezone
from typing import Any
from ..expiry import expiry_ms_from_ymd
_DATE_RE = re.compile(r"^\d{6}$")
__all__ = [
"expiry_ms_from_ymd",
"parse_option_inst_id",
"rows_to_option_contracts",
"safe_float",
]
def safe_float(v: Any) -> float | None:
if v is None or v == "":
@@ -31,13 +40,6 @@ def parse_option_inst_id(inst_id: str) -> tuple[str | None, float | None, str |
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]]:
"""
归一化为策略层可用的中性结构:
@@ -52,22 +54,26 @@ def rows_to_option_contracts(rows: list[dict[str, Any]]) -> list[dict[str, Any]]
continue
inst_id = str(row.get("instId") or "")
y, stk, opt = parse_option_inst_id(inst_id)
exp_ms = None
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")
exp_ms = ms
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
if exp_ms is None:
exp_ms = expiry_ms_from_ymd(y)
ct = safe_float(row.get("ctMult"))
out.append(
{
"inst_id": inst_id,
"expiry_ymd": y,
"expiry_ms": expiry_ms_from_ymd(y),
"expiry_ms": int(exp_ms),
"strike": float(stk),
"side": opt,
"ct_mult": float(ct) if ct and ct > 0 else None,
+86
View File
@@ -0,0 +1,86 @@
"""运行时交易所配置:DB 覆盖 env,切换时套用合约默认。"""
from __future__ import annotations
from ..config import EXCHANGE_MARKET_DEFAULTS, Settings, get_settings
def normalize_exchange_name(name: str | None) -> str:
n = (name or "okx").strip().lower()
if n in ("bn", "binance"):
return "binance"
return "okx"
def load_runtime_settings() -> Settings:
"""启动 / 切换后使用的有效 Settings(含 DB 覆盖)。"""
base = get_settings()
try:
from ..models.db import get_db
db = get_db()
except Exception:
return base
ex = normalize_exchange_name(db.get_setting("exchange", base.exchange))
defs = EXCHANGE_MARKET_DEFAULTS[ex]
ct_default = float(defs["option_ct_mult_default"])
raw_ct = db.get_setting("option_ct_mult_default")
if raw_ct not in (None, ""):
try:
ct_default = float(raw_ct)
except ValueError:
pass
return base.model_copy(
update={
"exchange": ex,
"perp_inst_id": str(
db.get_setting("perp_inst_id") or defs["perp_inst_id"]
),
"option_inst_family": str(
db.get_setting("option_inst_family") or defs["option_inst_family"]
),
"index_inst_id": str(
db.get_setting("index_inst_id") or defs["index_inst_id"]
),
"option_ct_mult_default": ct_default,
}
)
def persist_exchange_choice(name: str) -> Settings:
"""写入 exchange + 该所合约默认,返回 runtime settings。"""
from ..models.db import get_db
ex = normalize_exchange_name(name)
defs = EXCHANGE_MARKET_DEFAULTS[ex]
db = get_db()
db.set_setting("exchange", ex)
db.set_setting("perp_inst_id", str(defs["perp_inst_id"]))
db.set_setting("option_inst_family", str(defs["option_inst_family"]))
db.set_setting("index_inst_id", str(defs["index_inst_id"]))
db.set_setting("option_ct_mult_default", str(defs["option_ct_mult_default"]))
return load_runtime_settings()
async def reload_market_session(settings: Settings | None = None):
"""停旧会话、按 settings 重建交易所与策略会话并 start。"""
from .factory import set_exchange
from ..strategy.session import bootstrap_session, get_session, set_session
s = settings or load_runtime_settings()
old = None
try:
old = get_session()
except Exception:
old = None
if old is not None:
try:
await old.stop()
except Exception:
pass
set_session(None)
set_exchange(None)
sess = bootstrap_session(s)
await sess.start()
return sess
+8 -1
View File
@@ -89,6 +89,13 @@ async def health() -> dict:
from .strategy.session import get_session
settings = get_settings()
try:
from .exchange.runtime import load_runtime_settings
rt = load_runtime_settings()
exchange_name = rt.exchange
except Exception:
exchange_name = settings.exchange
sess = get_session()
snap = sess.snapshot()
try:
@@ -99,7 +106,7 @@ async def health() -> dict:
"ok": True,
"mode": settings.mode,
"env_name": settings.env_name,
"exchange": settings.exchange,
"exchange": exchange_name,
"sim": settings.is_sim,
"market_connected": snap.connected,
"pair": snap.pair.to_dict() if snap.pair else None,
+1 -1
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
from typing import Any
from ..exchange.expiry import expiry_ms_from_ymd
from ..exchange.okx.parse import (
expiry_ms_from_ymd,
parse_option_inst_id,
safe_float,
)
+6
View File
@@ -154,6 +154,7 @@ class Database:
"net_profit_target": str(s.net_profit_target),
"premium_exit_multiple": str(s.premium_exit_multiple),
"rest_seconds": str(s.rest_seconds),
"skip_weekends": str(s.skip_weekends),
"max_rounds": str(s.max_rounds),
"leverage": str(s.leverage),
"min_option_hours": str(s.min_option_hours),
@@ -161,6 +162,11 @@ class Database:
"close_bid_mark_max_pct": str(s.close_bid_mark_max_pct),
"perp_qty_eth": str(s.perp_qty_eth),
"option_qty_eth": str(s.option_qty_eth),
"exchange": str(s.exchange),
"perp_inst_id": str(s.perp_inst_id),
"option_inst_family": str(s.option_inst_family),
"index_inst_id": str(s.index_inst_id),
"option_ct_mult_default": str(s.option_ct_mult_default),
}
for k, v in defaults.items():
exists = self._conn.execute(
+7 -2
View File
@@ -40,7 +40,12 @@ class Matcher:
return self.ledger.get_setting_float("fee_rate", get_settings().fee_rate)
def _ct_mult(self, option_inst_id: str) -> float:
s = get_settings()
try:
from ..exchange.runtime import load_runtime_settings
s = load_runtime_settings()
except Exception:
s = get_settings()
try:
return get_exchange().get_ct_mult(
option_inst_id, s.option_inst_family, s.option_ct_mult_default
@@ -512,7 +517,7 @@ class Matcher:
expiry_ms = None
if expiry_ymd and len(expiry_ymd) == 6:
try:
from ..exchange.okx.parse import expiry_ms_from_ymd
from ..exchange.expiry import expiry_ms_from_ymd
expiry_ms = expiry_ms_from_ymd(expiry_ymd)
except Exception:
+1 -1
View File
@@ -154,7 +154,7 @@ class StrategyEngine:
ymd = upl.get("expiry_ymd")
if ymd:
try:
from ..exchange.okx.parse import expiry_ms_from_ymd
from ..exchange.expiry import expiry_ms_from_ymd
return int(expiry_ms_from_ymd(str(ymd)))
except Exception:
+2 -2
View File
@@ -25,7 +25,7 @@ def hours_until_expiry(
if expiry_ms is not None:
return hours_until_ms(expiry_ms, now)
# 兼容测试:无 ms 时按 OKX 惯例(UTC 08:00)推算
from ..exchange.okx.parse import expiry_ms_from_ymd
from ..exchange.expiry import expiry_ms_from_ymd
return hours_until_ms(expiry_ms_from_ymd(ymd), now)
@@ -76,7 +76,7 @@ def _complete_by_expiry(
if ymd in ms_map:
ems = ms_map[ymd]
else:
from ..exchange.okx.parse import expiry_ms_from_ymd
from ..exchange.expiry import expiry_ms_from_ymd
ems = expiry_ms_from_ymd(ymd)
out[ymd] = (ems, complete)
+7 -2
View File
@@ -286,7 +286,10 @@ class StrategySession:
return self.ex.snapshot(self.settings.perp_inst_id)
def snapshot_dict(self) -> dict[str, Any]:
return self.ex.snapshot_dict(self.settings.perp_inst_id)
d = self.ex.snapshot_dict(self.settings.perp_inst_id)
d["exchange"] = getattr(self.ex, "name", self.settings.exchange)
d["perp_inst_id"] = self.settings.perp_inst_id
return d
async def _refresh_loop(self) -> None:
while True:
@@ -328,7 +331,9 @@ set_gateway = set_session
def bootstrap_session(settings: Settings | None = None) -> StrategySession:
"""main 启动:创建交易所 + 策略会话。"""
s = settings or get_settings()
from ..exchange.runtime import load_runtime_settings
s = settings or load_runtime_settings()
ex = build_exchange(s)
set_exchange(ex)
sess = StrategySession(s, ex)
+59
View File
@@ -0,0 +1,59 @@
"""币安符号解析与中性到期工具测试。"""
from app.exchange.binance.parse import parse_option_symbol, rows_to_option_contracts
from app.exchange.expiry import expiry_ms_from_ymd, ymd_from_expiry_ms
from app.exchange.runtime import normalize_exchange_name
def test_binance_parse_option_symbol() -> None:
y, stk, side = parse_option_symbol("ETH-250726-1860-C")
assert y == "250726"
assert stk == 1860.0
assert side == "C"
y2, _, side2 = parse_option_symbol("ETH-250726-1860-P")
assert y2 == "250726" and side2 == "P"
assert parse_option_symbol("ETHUSDT")[0] is None
def test_binance_rows_to_contracts() -> None:
rows = [
{
"symbol": "ETH-250726-1860-C",
"status": "TRADING",
"strikePrice": "1860",
"side": "CALL",
"expiryDate": expiry_ms_from_ymd("250726"),
"unit": "1",
"underlying": "ETHUSDT",
},
{
"symbol": "ETH-250726-1860-P",
"status": "TRADING",
"strikePrice": "1860",
"side": "PUT",
"expiryDate": expiry_ms_from_ymd("250726"),
"unit": 1,
"underlying": "ETHUSDT",
},
]
out = rows_to_option_contracts(rows)
assert len(out) == 2
assert out[0]["expiry_ymd"] == "250726"
assert out[0]["ct_mult"] == 1.0
assert out[0]["side"] in ("C", "P")
def test_expiry_ms_roundtrip() -> None:
ms = expiry_ms_from_ymd("250726")
assert ymd_from_expiry_ms(ms) == "250726"
# UTC 08:00
from datetime import datetime, timezone
dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
assert dt.hour == 8 and dt.minute == 0
def test_normalize_exchange() -> None:
assert normalize_exchange_name("BN") == "binance"
assert normalize_exchange_name("okx") == "okx"
assert normalize_exchange_name(None) == "okx"
+11
View File
@@ -12,6 +12,15 @@
本质是 **概率与样本**:不追求每天固定轮次,而按行情吃机会。
**行情来源可切换(SIM**:系统设置中可选 **OKX****币安** 公共行情;成交仍为本机模拟撮合,**不下真单**。
| 交易所 | 永续(默认) | 期权 | 说明 |
|--------|--------------|------|------|
| OKX | ETH-USDT-SWAP | ETH-USD_UM(偏 USDC 保证金族) | 当前默认 |
| 币安 | ETHUSDTUSDT-M | 欧洲期权 ETH-YYMMDD-行权价-C/PUSDT | 后期实盘优先候选 |
有持仓时不可切换交易所;切换后自动套用该所合约并重连行情。
---
## 2. 仓位结构(默认)
@@ -208,6 +217,8 @@
系统默认虚拟权益 `initial_equity = 10,000` USDT(策略设置可改;保存且数值变更时在无持仓下重置账本),**不代表**实盘建议入金。
日后币安实盘试跑建议仓位:**永续 0.1 ETH / 期权 0.2 ETH 名义**,并同比下调净利目标。
---
## 8. 关键可配参数速查
+6
View File
@@ -88,6 +88,8 @@ export type MarketSnapshot = {
connected: boolean;
updated_at_ms: number | null;
index_px: number | null;
exchange?: string;
perp_inst_id?: string;
pair: {
expiry_ymd: string;
strike: number;
@@ -180,5 +182,9 @@ export type StrategySettings = {
close_bid_mark_max_pct: number;
perp_qty_eth: number;
option_qty_eth: number;
exchange: "okx" | "binance";
perp_inst_id?: string;
option_inst_family?: string;
index_inst_id?: string;
ledger: { equity: number; available: number };
};
+3 -1
View File
@@ -124,7 +124,9 @@ export default function PlanPage() {
<div>
<h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)", marginTop: -8 }}>
SIM · ·
SIM · {" "}
{(snap?.exchange || "okx").toUpperCase()}
{snap?.perp_inst_id ? ` · ${snap.perp_inst_id}` : ""} ·
</p>
{err ? <div className="err">{err}</div> : null}
+28 -2
View File
@@ -35,6 +35,7 @@ export default function SettingsPage() {
const [perpQty, setPerpQty] = useState(1);
const [optQty, setOptQty] = useState(2);
const [initialEquity, setInitialEquity] = useState(10000);
const [exchange, setExchange] = useState<"okx" | "binance">("okx");
const [stratOk, setStratOk] = useState("");
useEffect(() => {
@@ -53,6 +54,7 @@ export default function SettingsPage() {
setPerpQty(s.perp_qty_eth ?? 1);
setOptQty(s.option_qty_eth ?? 2);
setInitialEquity(s.initial_equity ?? 10000);
setExchange(s.exchange === "binance" ? "binance" : "okx");
})
.catch(() => undefined);
}, []);
@@ -109,10 +111,11 @@ export default function SettingsPage() {
perp_qty_eth: perpQty,
option_qty_eth: optQty,
initial_equity: initialEquity,
exchange,
}),
});
setStratOk(
"策略参数已保存(模拟资金仅在数值变更且无持仓时重置账本",
"策略参数已保存(切换交易所/改模拟资金需无持仓;切换后会重连行情",
);
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
@@ -142,11 +145,34 @@ export default function SettingsPage() {
{tab === "strategy" ? (
<div className="card settings-card">
<p className="settings-lead">
OKX / SIM
</p>
{stratOk ? <div className="settings-ok">{stratOk}</div> : null}
{err && tab === "strategy" ? <div className="err">{err}</div> : null}
<form onSubmit={onSaveStrategy}>
<section className="settings-section">
<h3></h3>
<div className="settings-fields">
<div className="field">
<label htmlFor="exch">SIM </label>
<select
id="exch"
className="mono"
value={exchange}
onChange={(e) =>
setExchange(e.target.value === "binance" ? "binance" : "okx")
}
>
<option value="okx">OKXUSDC </option>
<option value="binance">USDT + </option>
</select>
<p className="settings-hint">
</p>
</div>
</div>
</section>
<section className="settings-section">
<h3></h3>
<div className="settings-fields">