Add Binance SIM market adapter and exchange switch in settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+8
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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,4 +1,4 @@
|
||||
"""交易所模块:OKX 已接入,币安占位。策略不直接依赖具体交易所。"""
|
||||
"""交易所模块:OKX / 币安公共行情。策略不直接依赖具体交易所。"""
|
||||
|
||||
from .factory import build_exchange, get_exchange, set_exchange
|
||||
from .protocol import ExchangeMarket
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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. 关键可配参数速查
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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">OKX(USDC 期权族)</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">
|
||||
|
||||
Reference in New Issue
Block a user