From 78fd046fb64824225769774225ff488ac1c52e6d Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 25 Jul 2026 12:02:36 +0800 Subject: [PATCH] Add Binance SIM market adapter and exchange switch in settings. Co-authored-by: Cursor --- .env.example | 9 +- backend/app/api/market.py | 3 +- backend/app/api/settings.py | 40 +++++ backend/app/config.py | 26 ++- backend/app/exchange/__init__.py | 2 +- backend/app/exchange/binance/adapter.py | 94 +++++++++-- backend/app/exchange/binance/parse.py | 97 +++++++++++ backend/app/exchange/binance/rest.py | 155 ++++++++++++++++++ backend/app/exchange/binance/ws.py | 204 ++++++++++++++++++++++++ backend/app/exchange/expiry.py | 25 +++ backend/app/exchange/factory.py | 10 +- backend/app/exchange/okx/parse.py | 22 ++- backend/app/exchange/runtime.py | 86 ++++++++++ backend/app/main.py | 9 +- backend/app/market/instruments.py | 2 +- backend/app/models/db.py | 6 + backend/app/sim/matcher.py | 9 +- backend/app/strategy/engine.py | 2 +- backend/app/strategy/selection.py | 4 +- backend/app/strategy/session.py | 9 +- backend/tests/test_binance_parse.py | 59 +++++++ docs/策略说明.md | 11 ++ frontend/src/api/client.ts | 6 + frontend/src/pages/Plan.tsx | 4 +- frontend/src/pages/Settings.tsx | 30 +++- 25 files changed, 879 insertions(+), 45 deletions(-) create mode 100644 backend/app/exchange/binance/parse.py create mode 100644 backend/app/exchange/binance/rest.py create mode 100644 backend/app/exchange/binance/ws.py create mode 100644 backend/app/exchange/expiry.py create mode 100644 backend/app/exchange/runtime.py create mode 100644 backend/tests/test_binance_parse.py diff --git a/.env.example b/.env.example index 77e9c83..2808916 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ MODE=SIM ENV_NAME=test TZ=Asia/Shanghai -# 交易所模块:okx(已接入)| binance(占位) +# 交易所模块:okx | binance(SIM 公共行情;设置页可切换,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 diff --git a/backend/app/api/market.py b/backend/app/api/market.py index e444def..251109e 100644 --- a/backend/app/api/market.py +++ b/backend/app/api/market.py @@ -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 diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index bd7e519..a1c160c 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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() diff --git a/backend/app/config.py b/backend/app/config.py index 03fb51d..af27e50 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/exchange/__init__.py b/backend/app/exchange/__init__.py index 131a694..ab99325 100644 --- a/backend/app/exchange/__init__.py +++ b/backend/app/exchange/__init__.py @@ -1,4 +1,4 @@ -"""交易所模块:OKX 已接入,币安占位。策略不直接依赖具体交易所。""" +"""交易所模块:OKX / 币安公共行情。策略不直接依赖具体交易所。""" from .factory import build_exchange, get_exchange, set_exchange from .protocol import ExchangeMarket diff --git a/backend/app/exchange/binance/adapter.py b/backend/app/exchange/binance/adapter.py index 8c8560e..d262c69 100644 --- a/backend/app/exchange/binance/adapter.py +++ b/backend/app/exchange/binance/adapter.py @@ -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) diff --git a/backend/app/exchange/binance/parse.py b/backend/app/exchange/binance/parse.py new file mode 100644 index 0000000..96f46d9 --- /dev/null +++ b/backend/app/exchange/binance/parse.py @@ -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[A-Z0-9]+)-(?P\d{6})-(?P\d+(?:\.\d+)?)-(?P[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 diff --git a/backend/app/exchange/binance/rest.py b/backend/app/exchange/binance/rest.py new file mode 100644 index 0000000..04fa457 --- /dev/null +++ b/backend/app/exchange/binance/rest.py @@ -0,0 +1,155 @@ +"""币安只读 REST:USDT 永续 (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 diff --git a/backend/app/exchange/binance/ws.py b/backend/app/exchange/binance/ws.py new file mode 100644 index 0000000..86f256c --- /dev/null +++ b/backend/app/exchange/binance/ws.py @@ -0,0 +1,204 @@ +"""币安公共 WebSocket:USDT 永续 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) diff --git a/backend/app/exchange/expiry.py b/backend/app/exchange/expiry.py new file mode 100644 index 0000000..7e7805b --- /dev/null +++ b/backend/app/exchange/expiry.py @@ -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") diff --git a/backend/app/exchange/factory.py b/backend/app/exchange/factory.py index 86599a6..ac3bf98 100644 --- a/backend/app/exchange/factory.py +++ b/backend/app/exchange/factory.py @@ -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) diff --git a/backend/app/exchange/okx/parse.py b/backend/app/exchange/okx/parse.py index 8a27a0a..f2418bb 100644 --- a/backend/app/exchange/okx/parse.py +++ b/backend/app/exchange/okx/parse.py @@ -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, diff --git a/backend/app/exchange/runtime.py b/backend/app/exchange/runtime.py new file mode 100644 index 0000000..753f3b8 --- /dev/null +++ b/backend/app/exchange/runtime.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index f2da2ff..632a4f7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, diff --git a/backend/app/market/instruments.py b/backend/app/market/instruments.py index 3a9bdb1..3717227 100644 --- a/backend/app/market/instruments.py +++ b/backend/app/market/instruments.py @@ -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, ) diff --git a/backend/app/models/db.py b/backend/app/models/db.py index b8e49d5..fbf85e4 100644 --- a/backend/app/models/db.py +++ b/backend/app/models/db.py @@ -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( diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 6cc5019..f995c7f 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -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: diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index a01af83..5487699 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -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: diff --git a/backend/app/strategy/selection.py b/backend/app/strategy/selection.py index c1806bf..986e31b 100644 --- a/backend/app/strategy/selection.py +++ b/backend/app/strategy/selection.py @@ -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) diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index 6113016..a8b841d 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -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) diff --git a/backend/tests/test_binance_parse.py b/backend/tests/test_binance_parse.py new file mode 100644 index 0000000..2042e53 --- /dev/null +++ b/backend/tests/test_binance_parse.py @@ -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" diff --git a/docs/策略说明.md b/docs/策略说明.md index 78276f4..8a3866c 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -12,6 +12,15 @@ 本质是 **概率与样本**:不追求每天固定轮次,而按行情吃机会。 +**行情来源可切换(SIM)**:系统设置中可选 **OKX** 或 **币安** 公共行情;成交仍为本机模拟撮合,**不下真单**。 + +| 交易所 | 永续(默认) | 期权 | 说明 | +|--------|--------------|------|------| +| OKX | ETH-USDT-SWAP | ETH-USD_UM(偏 USDC 保证金族) | 当前默认 | +| 币安 | ETHUSDT(USDT-M) | 欧洲期权 ETH-YYMMDD-行权价-C/P(USDT) | 后期实盘优先候选 | + +有持仓时不可切换交易所;切换后自动套用该所合约并重连行情。 + --- ## 2. 仓位结构(默认) @@ -208,6 +217,8 @@ 系统默认虚拟权益 `initial_equity = 10,000` USDT(策略设置可改;保存且数值变更时在无持仓下重置账本),**不代表**实盘建议入金。 +日后币安实盘试跑建议仓位:**永续 0.1 ETH / 期权 0.2 ETH 名义**,并同比下调净利目标。 + --- ## 8. 关键可配参数速查 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index bfa686c..6a8f2b2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 }; }; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index d649e26..e8277a5 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -124,7 +124,9 @@ export default function PlanPage() {

自动对冲计划

- SIM 本地撮合 · 期权只买 · 净盈利达标或到期全平 + SIM 本地撮合 · 行情{" "} + {(snap?.exchange || "okx").toUpperCase()} + {snap?.perp_inst_id ? ` · ${snap.perp_inst_id}` : ""} · 净盈利达标或到期全平

{err ?
{err}
: null} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 7ce9925..b9d5ba7 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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" ? (

- 无开仓时间窗;可选周六日跳过开仓;期权按剩余时长选到期 → 平值 → 校验杠杆;出场可选固定金额或权利金倍数。 + 无开仓时间窗;可切换 OKX / 币安行情(SIM 本地撮合);周六日可跳过开仓;出场按净盈利或到期全平。

{stratOk ?
{stratOk}
: null} {err && tab === "strategy" ?
{err}
: null}
+
+

交易所

+
+
+ + +

+ 有持仓时不可切换。切换后自动套用该所合约并重连公共行情。 +

+
+
+
+

资金