From dbc86a1ce6bde3bd688cd7ad6d6ab9d10e748eb2 Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 26 Jul 2026 21:35:10 +0800 Subject: [PATCH] Add Binance live trading, anti-stuck open/close recovery, and configurable rate limits. OKX/Binance LIVE share half_open and option_closed_perp_pending repair paths; private REST throttles default to 1s and are tunable in settings. Co-authored-by: Cursor --- backend/app/api/settings.py | 8 + backend/app/config.py | 3 +- backend/app/env_store.py | 2 +- backend/app/live/__init__.py | 5 +- backend/app/live/binance_executor.py | 901 ++++++++++++++++++++++++++ backend/app/live/binance_trade.py | 237 +++++++ backend/app/live/executor.py | 468 +++++++++++-- backend/app/live/okx_trade.py | 29 +- backend/app/live/rate_limit.py | 196 ++++++ backend/app/models/db.py | 1 + backend/app/sim/ledger.py | 8 +- backend/app/sim/matcher.py | 20 +- backend/app/strategy/engine.py | 143 +++- backend/app/strategy/session.py | 15 +- backend/tests/test_live_rate_limit.py | 59 ++ backend/tests/test_live_recovery.py | 69 ++ backend/tests/test_runtime_mode.py | 12 +- docs/OKX实盘策略说明.md | 121 ++++ docs/实盘策略说明.md | 304 +-------- docs/币安实盘策略说明.md | 127 ++++ docs/策略说明.md | 6 +- frontend/src/api/client.ts | 1 + frontend/src/pages/Settings.tsx | 31 +- 23 files changed, 2381 insertions(+), 385 deletions(-) create mode 100644 backend/app/live/binance_executor.py create mode 100644 backend/app/live/binance_trade.py create mode 100644 backend/app/live/rate_limit.py create mode 100644 backend/tests/test_live_rate_limit.py create mode 100644 backend/tests/test_live_recovery.py create mode 100644 docs/OKX实盘策略说明.md create mode 100644 docs/币安实盘策略说明.md diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 7e5a2c0..64d9696 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -33,6 +33,7 @@ KEYS = ( "net_profit_target", "premium_exit_multiple", "rest_seconds", + "live_order_interval_sec", "skip_weekends", "initial_equity", "leverage", @@ -53,6 +54,7 @@ class StrategySettingsBody(BaseModel): net_profit_target: float | None = Field(default=None, ge=0.1, le=1_000_000) premium_exit_multiple: float | None = Field(default=None, ge=0.1, le=100) rest_seconds: int | None = Field(default=None, ge=0, le=3600) + live_order_interval_sec: float | None = Field(default=None, ge=0.2, le=30) skip_weekends: bool | None = None initial_equity: float | None = Field(default=None, ge=1000, le=10_000_000) leverage: float | None = Field(default=None, ge=1, le=125) @@ -96,6 +98,12 @@ def _read_settings() -> dict: "rest_seconds": int( float(db.get_setting("rest_seconds", str(s.rest_seconds)) or s.rest_seconds) ), + "live_order_interval_sec": float( + db.get_setting( + "live_order_interval_sec", str(s.live_order_interval_sec) + ) + or s.live_order_interval_sec + ), "skip_weekends": _as_bool( db.get_setting("skip_weekends", str(s.skip_weekends)), s.skip_weekends ), diff --git a/backend/app/config.py b/backend/app/config.py index bed76cc..4904eeb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -33,7 +33,7 @@ class Settings(BaseSettings): okx_ws_public: str = "wss://ws.okx.com:8443/ws/v5/public" okx_http_proxy: str = "" - # 币安私有交易密钥(本期仅落盘;实盘下单后续) + # 币安私有交易密钥(LIVE 真下单:fapi 永续 + eapi 期权) binance_api_key: str = "" binance_api_secret: str = "" @@ -61,6 +61,7 @@ class Settings(BaseSettings): net_profit_target: float = 15.0 # fixed_usdt:净盈利 ≥ 该值(USDT) premium_exit_multiple: float = 1.0 # premium_multiple:净盈利 ≥ 权利金×倍数 rest_seconds: int = 300 + live_order_interval_sec: float = 1.0 # LIVE 私有下单/查单最小间隔(秒) skip_weekends: bool = True # 上海时区周六日禁止新开仓(已有仓仍可平) leverage: float = 3.0 # 永续杠杆 min_option_hours: float = 12.0 # 期权最小剩余小时 diff --git a/backend/app/env_store.py b/backend/app/env_store.py index 9ac3387..3c8a1b2 100644 --- a/backend/app/env_store.py +++ b/backend/app/env_store.py @@ -59,7 +59,7 @@ def live_ready(*, exchange: str | None = None) -> tuple[bool, str]: if ex == "binance": if not binance_keys_configured(st): return False, "币安 API Key/Secret 未配置" - return False, "币安实盘下单尚未接入,请切回 OKX 或使用 SIM" + return True, "ok" if ex == "okx": if not okx_keys_configured(st): return False, "OKX API Key/Secret/Passphrase 未配置" diff --git a/backend/app/live/__init__.py b/backend/app/live/__init__.py index eeb6398..c4192d3 100644 --- a/backend/app/live/__init__.py +++ b/backend/app/live/__init__.py @@ -1,5 +1,6 @@ """实盘执行适配层。""" -from .executor import BinanceLiveStub, OkxLiveExecutor, get_executor +from .binance_executor import BinanceLiveExecutor +from .executor import OkxLiveExecutor, get_executor -__all__ = ["get_executor", "OkxLiveExecutor", "BinanceLiveStub"] +__all__ = ["get_executor", "OkxLiveExecutor", "BinanceLiveExecutor"] diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py new file mode 100644 index 0000000..aeee4fb --- /dev/null +++ b/backend/app/live/binance_executor.py @@ -0,0 +1,901 @@ +"""币安实盘执行:eapi 期权 + fapi 永续;先期权后永续(含 anti-stuck 状态机)。""" + +from __future__ import annotations + +import logging +import time + +from ..config import get_settings +from ..env_store import live_ready +from ..sim.liquidity import contracts_for_eth +from ..sim.matcher import CloseResult, Matcher, OpenResult +from ..sim.pricing import option_expiry_settle, option_intrinsic +from ..strategy.session import get_session +from .binance_trade import BinanceTradeClient + +logger = logging.getLogger(__name__) + + +class BinanceLiveExecutor(Matcher): + def __init__(self, db=None) -> None: + super().__init__(db) + self._trade: BinanceTradeClient | None = None + + def _client(self) -> BinanceTradeClient: + if self._trade is None: + self._trade = BinanceTradeClient() + return self._trade + + def _guard_live(self) -> str | None: + ok, reason = live_ready() + if not ok: + return reason + return None + + def open_group( + self, + *, + group_id: str, + bias: str, + option_side: str, + perp_side: str, + option_inst_id: str, + entry_index_px: float, + strike: float | None = None, + expiry_ymd: str | None = None, + ) -> OpenResult: + err = self._guard_live() + if err: + return OpenResult(ok=False, detail=err) + + s = get_settings() + if self.has_open_position(): + st = self.position_status() + return OpenResult( + ok=False, + detail=f"已有持仓/半仓状态({st}),请先修复或平仓", + ) + + client = self._client() + perp_qty = self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth) + opt_qty = self.ledger.get_setting_float("option_qty_eth", s.option_qty_eth) + ct_mult = self._ct_mult(option_inst_id) + opt_contracts = contracts_for_eth(opt_qty, ct_mult) + + try: + opt_fill = client.place_option_market( + symbol=option_inst_id, + side="BUY", + quantity=opt_contracts, + ) + except Exception as e: + logger.exception("binance live open option failed") + return OpenResult(ok=False, detail=f"币安开期权失败: {e}") + + # 永续市价失败(多为保证金不足)→ 必须回滚期权 + try: + if perp_side == "long": + side, pos_side = "BUY", "LONG" + else: + side, pos_side = "SELL", "SHORT" + perp_fill_live = client.place_perp_market( + symbol=s.perp_inst_id, + side=side, + qty_eth=perp_qty, + position_side=pos_side, + ) + except Exception as e: + logger.exception("binance live open perp failed (likely margin); rollback option") + try: + client.place_option_market( + symbol=option_inst_id, + side="SELL", + quantity=opt_contracts, + reduce_only=True, + ) + except Exception as e2: + logger.exception("binance option rollback failed: %s", e2) + self._persist_half_open( + group_id=group_id, + bias=bias, + option_side=option_side, + perp_side=perp_side, + option_inst_id=option_inst_id, + entry_index_px=entry_index_px, + strike=strike, + expiry_ymd=expiry_ymd, + opt_qty=opt_qty, + opt_contracts=opt_contracts, + of_px=float(opt_fill.avg_px), + of_fee=float(opt_fill.fee), + detail=f"保证金开永续失败且期权回滚失败: {e} / {e2}", + ) + return OpenResult( + ok=False, + group_id=group_id, + detail=f"永续开仓失败(保证金)且期权回滚失败,已标记 half_open: {e} / {e2}", + ) + return OpenResult( + ok=False, + detail=f"永续开仓失败(多为保证金不足),已回滚期权: {e}", + ) + + of_px = float(opt_fill.avg_px) + pf_px = float(perp_fill_live.avg_px) + of_fee = float(opt_fill.fee) + pf_fee = float(perp_fill_live.fee) + initial_premium = of_px * opt_qty + of_notional = of_px * opt_qty + pf_notional = pf_px * perp_qty + + # LIVE:交易所已成交,本地账本允许透支镜像,禁止因账本拒记导致「交易所有仓、DB 空」 + self.ledger.apply_cash( + -(of_notional + of_fee), + kind="open_option", + group_id=group_id, + note=f"LIVE-BN open option {group_id}", + allow_negative=True, + ) + self.ledger.apply_cash( + -pf_fee, + kind="open_perp_fee", + group_id=group_id, + note=f"LIVE-BN open perp {group_id}", + allow_negative=True, + ) + + now = int(time.time() * 1000) + with self.db._lock: + self.db._conn.execute( + """INSERT INTO groups( + group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id, + strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost, + exec_mode + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "open", + bias, + option_side, + perp_side, + option_inst_id, + s.perp_inst_id, + strike, + expiry_ymd, + entry_index_px, + initial_premium, + now, + of_fee + pf_fee, + 0.0, + "LIVE", + ), + ) + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "option", + "open", + "long", + option_inst_id, + opt_qty, + opt_contracts, + of_px, + of_px, + of_fee, + 0.0, + of_notional, + now, + "LIVE", + ), + ) + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "perp", + "open", + perp_side, + s.perp_inst_id, + perp_qty, + None, + pf_px, + pf_px, + pf_fee, + 0.0, + pf_notional, + now + 1, + "LIVE", + ), + ) + self.db._conn.execute( + """UPDATE positions SET + group_id=?, perp_side=?, perp_qty_eth=?, perp_entry_px=?, + option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?, + option_entry_px=?, entry_index_px=?, initial_premium=?, status=? + WHERE id=1""", + ( + group_id, + perp_side, + perp_qty, + pf_px, + option_inst_id, + option_side, + opt_qty, + opt_contracts, + of_px, + entry_index_px, + initial_premium, + "open", + ), + ) + self.db._conn.commit() + + return OpenResult( + ok=True, + group_id=group_id, + detail="opened_live_binance", + data={ + "group_id": group_id, + "exec_mode": "LIVE", + "exchange": "binance", + "option_ord": opt_fill.ord_id, + "perp_ord": perp_fill_live.ord_id, + "initial_premium": initial_premium, + "fees": of_fee + pf_fee, + }, + ) + + def _persist_half_open( + self, + *, + group_id: str, + bias: str, + option_side: str, + perp_side: str, + option_inst_id: str, + entry_index_px: float, + strike: float | None, + expiry_ymd: str | None, + opt_qty: float, + opt_contracts: float, + of_px: float, + of_fee: float, + detail: str, + ) -> None: + """期权已成交、永续未开且回滚失败 → 落 half_open,禁止新开,待 repair。""" + s = get_settings() + initial_premium = of_px * opt_qty + self.ledger.apply_cash( + -(of_px * opt_qty + of_fee), + kind="open_option", + group_id=group_id, + note=f"LIVE-BN half_open option {group_id}", + allow_negative=True, + ) + now = int(time.time() * 1000) + with self.db._lock: + existing = self.db._conn.execute( + "SELECT group_id FROM groups WHERE group_id=?", (group_id,) + ).fetchone() + if existing is None: + self.db._conn.execute( + """INSERT INTO groups( + group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id, + strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost, + exec_mode, note + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "half_open", + bias, + option_side, + perp_side, + option_inst_id, + s.perp_inst_id, + strike, + expiry_ymd, + entry_index_px, + initial_premium, + now, + of_fee, + 0.0, + "LIVE", + detail[:200], + ), + ) + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "option", + "open", + "long", + option_inst_id, + opt_qty, + opt_contracts, + of_px, + of_px, + of_fee, + 0.0, + of_px * opt_qty, + now, + "LIVE", + ), + ) + self.db._conn.execute( + """UPDATE positions SET + group_id=?, perp_side=?, perp_qty_eth=0, perp_entry_px=NULL, + option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?, + option_entry_px=?, entry_index_px=?, initial_premium=?, status='half_open' + WHERE id=1""", + ( + group_id, + perp_side, + option_inst_id, + option_side, + opt_qty, + opt_contracts, + of_px, + entry_index_px, + initial_premium, + ), + ) + self.db._conn.commit() + + def repair_half_open(self) -> CloseResult: + """卖出 half_open 残留期权,清本地状态。""" + err = self._guard_live() + if err: + return CloseResult(ok=False, detail=err) + pos = self.current_position() + if pos.get("status") != "half_open": + return CloseResult(ok=False, detail="非 half_open 状态") + group_id = str(pos.get("group_id") or "") + option_inst_id = str(pos.get("option_inst_id") or "") + opt_contracts = float(pos.get("option_qty_contracts") or 0) + opt_qty = float(pos.get("option_qty_eth") or 0) + if not option_inst_id or opt_contracts <= 0: + return CloseResult(ok=False, detail="half_open 缺期权合约信息") + client = self._client() + try: + opt_live = client.place_option_market( + symbol=option_inst_id, + side="SELL", + quantity=opt_contracts, + reduce_only=True, + ) + except Exception as e: + return CloseResult(ok=False, detail=f"half_open 平期权失败: {e}") + of_px = float(opt_live.avg_px) + of_fee = float(opt_live.fee) + of_notional = of_px * opt_qty + opt_entry = float(pos.get("option_entry_px") or of_px) + self.ledger.apply_cash( + of_notional - of_fee, + kind="close_option", + group_id=group_id or None, + note="LIVE-BN repair half_open", + allow_negative=True, + ) + now = int(time.time() * 1000) + with self.db._lock: + if group_id: + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "option", + "close", + "flat", + option_inst_id, + opt_qty, + opt_contracts, + of_px, + of_px, + of_fee, + 0.0, + of_notional, + now, + "LIVE", + ), + ) + opt_pnl = (of_px - opt_entry) * opt_qty - of_fee + self.db._conn.execute( + """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, note=? + WHERE group_id=?""", + ( + "closed", + now, + "half_open_repair", + float(opt_pnl), + "repaired half_open", + group_id, + ), + ) + self.db._conn.execute( + """UPDATE positions SET + group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL, + option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0, + option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat' + WHERE id=1""" + ) + self.db._conn.commit() + return CloseResult( + ok=True, + detail="half_open_repaired", + data={"group_id": group_id, "exec_mode": "LIVE", "exchange": "binance"}, + ) + + def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult: + err = self._guard_live() + if err: + return CloseResult(ok=False, detail=err) + + s = get_settings() + pos = self.current_position() + st = str(pos.get("status") or "") + if st == "half_open": + return self.repair_half_open() + if st not in ("open", "option_closed_perp_pending") or not pos.get("group_id"): + return CloseResult(ok=False, detail="无持仓可平") + + group_id = str(pos["group_id"]) + option_inst_id = str(pos["option_inst_id"]) + option_side = str(pos["option_side"]) + perp_side = str(pos["perp_side"]) + opt_qty = float(pos["option_qty_eth"]) + perp_qty = float(pos["perp_qty_eth"]) + opt_contracts = float(pos["option_qty_contracts"] or 0) + client = self._client() + is_expiry = reason == "expiry" + fee_rate = self._fee_rate() + pending_perp_only = st == "option_closed_perp_pending" + + sess = get_session() + snap = sess.snapshot() + strike = self._group_strike(group_id, option_inst_id) + spot = self._close_spot_px(snap) + intrinsic = None + if strike is not None and spot is not None: + intrinsic = option_intrinsic( + option_side=option_side, strike=float(strike), spot=float(spot) + ) + + of_px = 0.0 + of_fee = 0.0 + of_slip = 0.0 + of_notional = 0.0 + + if pending_perp_only: + # 期权已在上次成交并入账;只读上次平期权 fill + prev = self.db.fetchone( + """SELECT fill_px, fee, notional, slip FROM fills + WHERE group_id=? AND leg='option' AND action='close' + ORDER BY id DESC LIMIT 1""", + (group_id,), + ) + if prev is None: + return CloseResult( + ok=False, + detail="option_closed_perp_pending 缺期权平仓记录,请人工核对", + ) + of_px = float(prev["fill_px"]) + of_fee = float(prev["fee"] or 0) + of_notional = float(prev["notional"] or (of_px * opt_qty)) + of_slip = float(prev["slip"] or 0) + elif is_expiry: + if intrinsic is None: + return CloseResult(ok=False, detail="到期结算失败:缺行权价或标的价") + of = option_expiry_settle( + intrinsic=float(intrinsic), qty_eth=opt_qty, fee_rate=fee_rate + ) + of_px, of_fee, of_slip, of_notional = of.fill_px, of.fee, of.slip, of.notional + else: + try: + opt_live = client.place_option_market( + symbol=option_inst_id, + side="SELL", + quantity=opt_contracts, + reduce_only=True, + ) + of_px = float(opt_live.avg_px) + of_fee = float(opt_live.fee) + of_notional = of_px * opt_qty + except Exception as e: + if not bypass_liquidity: + return CloseResult( + ok=False, + detail=f"币安平期权失败: {e}", + liquidity_wait=True, + ) + return CloseResult(ok=False, detail=f"币安平期权失败: {e}") + + # 期权已平:立刻落 pending,避免永续失败后重试再卖期权 + self._mark_option_closed_perp_pending( + group_id=group_id, + option_inst_id=option_inst_id, + opt_qty=opt_qty, + opt_contracts=opt_contracts, + of_px=of_px, + of_fee=of_fee, + of_notional=of_notional, + of_slip=of_slip, + reason=reason, + ) + pending_perp_only = True + + try: + if perp_side == "long": + side, pos_side = "SELL", "LONG" + else: + side, pos_side = "BUY", "SHORT" + perp_live = client.place_perp_market( + symbol=s.perp_inst_id, + side=side, + qty_eth=perp_qty, + position_side=pos_side, + reduce_only=True, + ) + pf_px = float(perp_live.avg_px) + pf_fee = float(perp_live.fee) + except Exception as e: + return CloseResult( + ok=False, + detail=f"期权已平,永续待平(option_closed_perp_pending): {e}", + ) + + return self._finalize_dual_close( + pos=pos, + group_id=group_id, + option_inst_id=option_inst_id, + opt_qty=opt_qty, + opt_contracts=opt_contracts, + of_px=of_px, + of_fee=of_fee, + of_slip=of_slip, + of_notional=of_notional, + pf_px=pf_px, + pf_fee=pf_fee, + reason=reason, + option_fill_already_written=( + st == "option_closed_perp_pending" + or (pending_perp_only and not is_expiry) + ), + skip_option_cash=( + st == "option_closed_perp_pending" + or (pending_perp_only and not is_expiry) + ), + ) + + def _mark_option_closed_perp_pending( + self, + *, + group_id: str, + option_inst_id: str, + opt_qty: float, + opt_contracts: float, + of_px: float, + of_fee: float, + of_notional: float, + of_slip: float, + reason: str, + ) -> None: + self.ledger.apply_cash( + of_notional - of_fee, + kind="close_option", + group_id=group_id, + note=f"LIVE-BN close option pending perp {reason}", + allow_negative=True, + ) + now = int(time.time() * 1000) + with self.db._lock: + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "option", + "close", + "flat", + option_inst_id, + opt_qty, + opt_contracts, + of_px, + of_px, + of_fee, + of_slip, + of_notional, + now, + "LIVE", + ), + ) + self.db._conn.execute( + "UPDATE positions SET status='option_closed_perp_pending' WHERE id=1" + ) + self.db._conn.execute( + "UPDATE groups SET fees=COALESCE(fees,0)+?, note=? WHERE group_id=?", + (of_fee, f"option_closed_perp_pending:{reason}", group_id), + ) + self.db._conn.commit() + + def _finalize_dual_close( + self, + *, + pos: dict, + group_id: str, + option_inst_id: str, + opt_qty: float, + opt_contracts: float, + of_px: float, + of_fee: float, + of_slip: float, + of_notional: float, + pf_px: float, + pf_fee: float, + reason: str, + option_fill_already_written: bool, + skip_option_cash: bool, + ) -> CloseResult: + s = get_settings() + perp_side = str(pos["perp_side"]) + perp_qty = float(pos["perp_qty_eth"]) + opt_entry = float(pos["option_entry_px"]) + perp_entry = float(pos["perp_entry_px"] or pf_px) + opt_pnl = (of_px - opt_entry) * opt_qty + if perp_side == "long": + perp_pnl = (pf_px - perp_entry) * perp_qty + else: + perp_pnl = (perp_entry - pf_px) * perp_qty + + if not skip_option_cash: + self.ledger.apply_cash( + of_notional - of_fee, + kind="close_option", + group_id=group_id, + note=f"LIVE-BN close option {reason}", + allow_negative=True, + ) + self.ledger.apply_cash( + perp_pnl - pf_fee, + kind="close_perp", + group_id=group_id, + note=f"LIVE-BN close perp {reason}", + allow_negative=True, + ) + + now = int(time.time() * 1000) + g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,)) + base_fees = float((g["fees"] if g else 0) or 0) + fees = base_fees + (0.0 if skip_option_cash else of_fee) + pf_fee + slip = float((g["slip_cost"] if g else 0) or 0) + ( + 0.0 if option_fill_already_written else of_slip + ) + from ..sim.pnl import summarize_fills_pnl + + with self.db._lock: + if not option_fill_already_written: + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "option", + "close", + "flat", + option_inst_id, + opt_qty, + opt_contracts, + of_px, + of_px, + of_fee, + of_slip, + of_notional, + now, + "LIVE", + ), + ) + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "perp", + "close", + "flat", + s.perp_inst_id, + perp_qty, + None, + pf_px, + pf_px, + pf_fee, + 0.0, + pf_px * perp_qty, + now + 1, + "LIVE", + ), + ) + fills = self.db._conn.execute( + "SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,) + ).fetchall() + summary = summarize_fills_pnl(list(fills)) + net = summary.get("net_pnl") + if net is None: + net = opt_pnl + perp_pnl - of_fee - pf_fee + self.db._conn.execute( + """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, + fees=?, slip_cost=? WHERE group_id=?""", + ("closed", now, reason, float(net), fees, slip, group_id), + ) + self.db._conn.execute( + """UPDATE positions SET + group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL, + option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0, + option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat' + WHERE id=1""" + ) + self.db._conn.commit() + + return CloseResult( + ok=True, + detail="closed_live_binance", + data={"group_id": group_id, "reason": reason, "net_pnl": net, "exec_mode": "LIVE"}, + ) + + def close_perp_abandon_option( + self, *, reason: str = "target_perp_only", require_deep_otm: bool = True + ) -> CloseResult: + err = self._guard_live() + if err: + return CloseResult(ok=False, detail=err) + if require_deep_otm and not self.option_is_deep_otm(): + return CloseResult(ok=False, detail="期权非远虚,应走双腿全平") + + s = get_settings() + pos = self.current_position() + st = str(pos.get("status") or "") + if st not in ("open", "option_closed_perp_pending") or not pos.get("group_id"): + return CloseResult(ok=False, detail="无持仓可平") + # 若期权已平只剩永续,走 close_group 续平即可 + if st == "option_closed_perp_pending": + return self.close_group(reason=reason, bypass_liquidity=True) + + group_id = str(pos["group_id"]) + perp_side = str(pos["perp_side"]) + perp_qty = float(pos["perp_qty_eth"]) + perp_entry = float(pos["perp_entry_px"]) + client = self._client() + try: + if perp_side == "long": + side, pos_side = "SELL", "LONG" + else: + side, pos_side = "BUY", "SHORT" + perp_live = client.place_perp_market( + symbol=s.perp_inst_id, + side=side, + qty_eth=perp_qty, + position_side=pos_side, + reduce_only=True, + ) + except Exception as e: + return CloseResult(ok=False, detail=f"币安平永续失败: {e}") + + pf_px = float(perp_live.avg_px) + pf_fee = float(perp_live.fee) + if perp_side == "long": + perp_pnl = (pf_px - perp_entry) * perp_qty + else: + perp_pnl = (perp_entry - pf_px) * perp_qty + + self.ledger.apply_cash( + perp_pnl - pf_fee, + kind="close_perp", + group_id=group_id, + note=f"LIVE-BN close perp abandon option {reason}", + ) + + option_inst_id = str(pos["option_inst_id"]) + option_side = str(pos["option_side"]) + strike = self._group_strike(group_id, option_inst_id) + g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,)) + expiry_ymd = str(g["expiry_ymd"]) if g and g["expiry_ymd"] else None + expiry_ms = None + if expiry_ymd: + try: + from ..exchange.expiry import expiry_ms_from_ymd + + expiry_ms = int(expiry_ms_from_ymd(expiry_ymd)) + except Exception: + expiry_ms = None + + now = int(time.time() * 1000) + open_fees = float((g["fees"] if g else 0) or 0) + fees = open_fees + pf_fee + slip = float((g["slip_cost"] if g else 0) or 0) + interim_net = perp_pnl - open_fees - pf_fee + spot = self._close_spot_px(get_session().snapshot()) + + with self.db._lock: + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "perp", + "close", + "flat", + s.perp_inst_id, + perp_qty, + None, + pf_px, + pf_px, + pf_fee, + 0.0, + pf_px * perp_qty, + now, + "LIVE", + ), + ) + self.db._conn.execute( + """INSERT INTO residual_options( + group_id, option_inst_id, option_side, option_qty_eth, option_qty_contracts, + option_entry_px, strike, expiry_ymd, expiry_ms, entry_index_px, + initial_premium, status, created_at_ms, note + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + option_inst_id, + option_side, + float(pos["option_qty_eth"]), + float(pos["option_qty_contracts"] or 0), + float(pos["option_entry_px"]), + float(strike) if strike is not None else None, + expiry_ymd, + expiry_ms, + float(pos["entry_index_px"] or 0), + float(pos["initial_premium"] or 0), + "pending", + now, + f"LIVE-BN abandoned after {reason}; spot={spot}", + ), + ) + self.db._conn.execute( + """UPDATE groups SET status=?, close_reason=?, realized_pnl=?, + fees=?, slip_cost=?, note=?, exec_mode=? WHERE group_id=?""", + ( + "option_residual", + reason, + interim_net, + fees, + slip, + "LIVE-BN perp_closed; option residual until expiry", + "LIVE", + group_id, + ), + ) + self.db._conn.execute( + """UPDATE positions SET + group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL, + option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0, + option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat' + WHERE id=1""" + ) + self.db._conn.commit() + + return CloseResult( + ok=True, + detail="perp_closed_option_residual_live_binance", + data={"group_id": group_id, "reason": reason, "mode": "target_perp_only", "exec_mode": "LIVE"}, + ) diff --git a/backend/app/live/binance_trade.py b/backend/app/live/binance_trade.py new file mode 100644 index 0000000..6cc1db5 --- /dev/null +++ b/backend/app/live/binance_trade.py @@ -0,0 +1,237 @@ +"""币安私有交易:USDT-M 永续 (fapi) + 欧洲期权 (eapi)。""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import time +from typing import Any +from urllib.parse import urlencode + +import httpx + +from ..config import Settings, get_settings +from ..exchange.okx.parse import safe_float +from .okx_trade import LiveFill +from .rate_limit import RateLimitError, get_throttle, parse_retry_after_header + +logger = logging.getLogger(__name__) + + +class BinanceTradeClient: + def __init__(self, settings: Settings | None = None) -> None: + self.settings = settings or get_settings() + proxy = (self.settings.binance_http_proxy or "").strip() or None + headers = { + "Accept": "application/json", + "User-Agent": "eth-hedge-live/0.1", + "X-MBX-APIKEY": self.settings.binance_api_key or "", + } + self._fapi = httpx.Client( + base_url=self.settings.binance_fapi_base.rstrip("/"), + timeout=20.0, + proxy=proxy, + headers=headers, + trust_env=False, + ) + self._eapi = httpx.Client( + base_url=self.settings.binance_eapi_base.rstrip("/"), + timeout=20.0, + proxy=proxy, + headers=headers, + trust_env=False, + ) + self._hedge: bool | None = None + self._fapi_throttle = get_throttle("binance_fapi_trade", min_interval_sec=1.0) + self._eapi_throttle = get_throttle( + "binance_eapi_trade", + min_interval_sec=1.0, + cooldown_429_sec=20.0, + cooldown_418_sec=120.0, + ) + + def close(self) -> None: + self._fapi.close() + self._eapi.close() + + def _sign(self, params: dict[str, Any]) -> str: + qs = urlencode(params, doseq=True) + secret = (self.settings.binance_api_secret or "").encode("utf-8") + return hmac.new(secret, qs.encode("utf-8"), hashlib.sha256).hexdigest() + + def _throttle_for(self, client: httpx.Client): + if client is self._eapi: + return self._eapi_throttle + return self._fapi_throttle + + def _signed( + self, + client: httpx.Client, + method: str, + path: str, + params: dict[str, Any] | None = None, + ) -> Any: + throttle = self._throttle_for(client) + throttle.before_request() + p = dict(params or {}) + p["timestamp"] = int(time.time() * 1000) + p["signature"] = self._sign(p) + r = client.request(method.upper(), path, params=p) + if r.status_code in (418, 429): + ra = parse_retry_after_header(r.headers) + throttle.mark_http(r.status_code, ra) + raise RateLimitError( + f"Binance {path} HTTP {r.status_code}: {r.text[:200]}", + retry_after=throttle.remaining_cooldown(), + ) + if r.status_code >= 400: + raise RuntimeError(f"Binance {path} HTTP {r.status_code}: {r.text[:400]}") + data = r.json() + if isinstance(data, dict) and "code" in data and "orderId" not in data: + code = data.get("code") + try: + code_i = int(code) + except (TypeError, ValueError): + code_i = None + msg = str(data.get("msg") or "") + # -1003 too many requests; -1015 too many orders + if code_i in (-1003, -1015) or "too many" in msg.lower(): + throttle.mark_seconds(20.0) + raise RateLimitError( + f"Binance rate-limited code={code} msg={msg}", + retry_after=throttle.remaining_cooldown(), + ) + if code_i is not None and code_i != 0: + raise RuntimeError(f"Binance error code={code} msg={msg}") + if code_i is None: + raise RuntimeError(f"Binance error code={code} msg={msg}") + return data + + def is_hedge_mode(self) -> bool: + if self._hedge is not None: + return self._hedge + try: + data = self._signed(self._fapi, "GET", "/fapi/v1/positionSide/dual") + self._hedge = bool(data.get("dualSidePosition") in (True, "true", "True")) + except Exception as e: + logger.warning("binance hedge mode probe failed: %s; assume one-way", e) + self._hedge = False + return self._hedge + + def place_perp_market( + self, + *, + symbol: str, + side: str, # BUY|SELL + qty_eth: float, + position_side: str | None = None, # LONG|SHORT|None + reduce_only: bool = False, + ) -> LiveFill: + # ETHUSDT 数量单位为 ETH + qty = f"{float(qty_eth):.3f}".rstrip("0").rstrip(".") + if not qty or qty == "0": + qty = "0.001" + params: dict[str, Any] = { + "symbol": symbol, + "side": side.upper(), + "type": "MARKET", + "quantity": qty, + } + hedge = self.is_hedge_mode() + if hedge: + ps = (position_side or ("LONG" if side.upper() == "BUY" else "SHORT")).upper() + params["positionSide"] = ps + elif reduce_only: + params["reduceOnly"] = "true" + data = self._signed(self._fapi, "POST", "/fapi/v1/order", params) + return self._fill_from_fapi(symbol, data) + + def _fill_from_fapi(self, symbol: str, data: dict[str, Any]) -> LiveFill: + ord_id = str(data.get("orderId") or "") + avg = safe_float(data.get("avgPrice")) + sz = safe_float(data.get("executedQty")) + if (not avg or avg <= 0) and ord_id: + q = self._signed( + self._fapi, + "GET", + "/fapi/v1/order", + {"symbol": symbol, "orderId": ord_id}, + ) + avg = safe_float(q.get("avgPrice")) or avg + sz = safe_float(q.get("executedQty")) or sz + data = q + if not avg or avg <= 0: + raise RuntimeError(f"币安永续无成交均价: {data}") + # 手续费:优先 cumCommission;否则用名义×费率估 + fee = abs(safe_float(data.get("cumCommission")) or 0.0) + if fee <= 0: + fee = float(avg) * float(sz or 0) * float(self.settings.fee_rate) + return LiveFill( + inst_id=symbol, + side=str(data.get("side") or "").lower(), + avg_px=float(avg), + sz=float(sz or 0), + fee=float(fee), + ord_id=ord_id, + raw=data if isinstance(data, dict) else {}, + ) + + def place_option_market( + self, + *, + symbol: str, + side: str, # BUY|SELL + quantity: float, + reduce_only: bool = False, + ) -> LiveFill: + qty = str(int(round(quantity))) + if qty == "0": + qty = "1" + params: dict[str, Any] = { + "symbol": symbol, + "side": side.upper(), + "type": "MARKET", + "quantity": qty, + } + if reduce_only: + params["reduceOnly"] = "true" + data = self._signed(self._eapi, "POST", "/eapi/v1/order", params) + return self._fill_from_eapi(symbol, data) + + def _fill_from_eapi(self, symbol: str, data: dict[str, Any]) -> LiveFill: + ord_id = str(data.get("orderId") or data.get("id") or "") + avg = safe_float(data.get("avgPrice")) or safe_float(data.get("price")) + sz = safe_float(data.get("executedQty")) or safe_float(data.get("quantity")) + if (not avg or avg <= 0) and ord_id: + # 轮询几轮 + for _ in range(8): + time.sleep(0.2) + q = self._signed( + self._eapi, + "GET", + "/eapi/v1/order", + {"symbol": symbol, "orderId": ord_id}, + ) + avg = safe_float(q.get("avgPrice")) or safe_float(q.get("price")) + sz = safe_float(q.get("executedQty")) or safe_float(q.get("quantity")) + st = str(q.get("status") or "").upper() + data = q + if avg and avg > 0 and st in ("FILLED", "PARTIALLY_FILLED"): + break + if st in ("CANCELED", "REJECTED", "EXPIRED"): + raise RuntimeError(f"币安期权订单失败 status={st} {q}") + if not avg or avg <= 0: + raise RuntimeError(f"币安期权无成交均价: {data}") + fee = abs(safe_float(data.get("fee")) or 0.0) + if fee <= 0: + fee = float(avg) * float(sz or 0) * float(self.settings.fee_rate) + return LiveFill( + inst_id=symbol, + side=str(data.get("side") or "").lower(), + avg_px=float(avg), + sz=float(sz or 0), + fee=float(fee), + ord_id=ord_id, + raw=data if isinstance(data, dict) else {}, + ) diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index 1504283..d396558 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging import time -from typing import Any from ..config import get_settings from ..env_store import live_ready @@ -54,9 +53,12 @@ class OkxLiveExecutor(Matcher): return OpenResult(ok=False, detail=err) s = get_settings() - pos = self.current_position() - if pos.get("status") == "open" and pos.get("group_id"): - return OpenResult(ok=False, detail="已有持仓组,请先平仓") + if self.has_open_position(): + st = self.position_status() + return OpenResult( + ok=False, + detail=f"已有持仓/半仓状态({st}),请先修复或平仓", + ) client = self._client() perp_qty = self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth) @@ -76,7 +78,7 @@ class OkxLiveExecutor(Matcher): logger.exception("live open option failed") return OpenResult(ok=False, detail=f"实盘开期权失败: {e}") - # 永续:按仓位方向 + # 永续市价:按产品假设,失败原因实质为保证金不足 → 必须回滚期权 try: ct_val = client.get_ct_val(s.perp_inst_id, inst_type="SWAP") perp_sz = max(1, int(round(perp_qty / ct_val))) @@ -92,7 +94,7 @@ class OkxLiveExecutor(Matcher): pos_side=pos_side, ) except Exception as e: - logger.exception("live open perp failed; attempting option close") + logger.exception("live open perp failed (likely margin); rollback option") try: client.place_market( inst_id=option_inst_id, @@ -103,11 +105,30 @@ class OkxLiveExecutor(Matcher): ) except Exception as e2: logger.exception("live option rollback failed: %s", e2) + self._persist_half_open( + group_id=group_id, + bias=bias, + option_side=option_side, + perp_side=perp_side, + option_inst_id=option_inst_id, + entry_index_px=entry_index_px, + strike=strike, + expiry_ymd=expiry_ymd, + opt_qty=opt_qty, + opt_contracts=opt_contracts, + of_px=float(opt_fill.avg_px), + of_fee=float(opt_fill.fee), + detail=f"保证金开永续失败且期权回滚失败: {e} / {e2}", + ) return OpenResult( ok=False, - detail=f"永续开仓失败且期权回滚失败: {e} / {e2}", + group_id=group_id, + detail=f"永续开仓失败(保证金)且期权回滚失败,已标记 half_open: {e} / {e2}", ) - return OpenResult(ok=False, detail=f"永续开仓失败,已尝试平期权: {e}") + return OpenResult( + ok=False, + detail=f"永续开仓失败(多为保证金不足),已回滚期权: {e}", + ) of_px = float(opt_fill.avg_px) pf_px = float(perp_fill_live.avg_px) @@ -117,21 +138,21 @@ class OkxLiveExecutor(Matcher): of_notional = of_px * opt_qty pf_notional = pf_px * perp_qty - try: - self.ledger.apply_cash( - -(of_notional + of_fee), - kind="open_option", - group_id=group_id, - note=f"LIVE open option {group_id}", - ) - self.ledger.apply_cash( - -pf_fee, - kind="open_perp_fee", - group_id=group_id, - note=f"LIVE open perp {group_id}", - ) - except RuntimeError as e: - return OpenResult(ok=False, detail=str(e)) + # LIVE:交易所已成交,本地账本允许透支镜像,禁止因账本拒记导致「交易所有仓、DB 空」 + self.ledger.apply_cash( + -(of_notional + of_fee), + kind="open_option", + group_id=group_id, + note=f"LIVE open option {group_id}", + allow_negative=True, + ) + self.ledger.apply_cash( + -pf_fee, + kind="open_perp_fee", + group_id=group_id, + note=f"LIVE open perp {group_id}", + allow_negative=True, + ) now = int(time.time() * 1000) with self.db._lock: @@ -238,6 +259,192 @@ class OkxLiveExecutor(Matcher): }, ) + def _persist_half_open( + self, + *, + group_id: str, + bias: str, + option_side: str, + perp_side: str, + option_inst_id: str, + entry_index_px: float, + strike: float | None, + expiry_ymd: str | None, + opt_qty: float, + opt_contracts: float, + of_px: float, + of_fee: float, + detail: str, + ) -> None: + """期权已成交、永续未开且回滚失败 → 落 half_open,禁止新开,待 repair。""" + s = get_settings() + initial_premium = of_px * opt_qty + self.ledger.apply_cash( + -(of_px * opt_qty + of_fee), + kind="open_option", + group_id=group_id, + note=f"LIVE half_open option {group_id}", + allow_negative=True, + ) + now = int(time.time() * 1000) + with self.db._lock: + existing = self.db._conn.execute( + "SELECT group_id FROM groups WHERE group_id=?", (group_id,) + ).fetchone() + if existing is None: + self.db._conn.execute( + """INSERT INTO groups( + group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id, + strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost, + exec_mode, note + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "half_open", + bias, + option_side, + perp_side, + option_inst_id, + s.perp_inst_id, + strike, + expiry_ymd, + entry_index_px, + initial_premium, + now, + of_fee, + 0.0, + "LIVE", + detail[:200], + ), + ) + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "option", + "open", + "long", + option_inst_id, + opt_qty, + opt_contracts, + of_px, + of_px, + of_fee, + 0.0, + of_px * opt_qty, + now, + "LIVE", + ), + ) + self.db._conn.execute( + """UPDATE positions SET + group_id=?, perp_side=?, perp_qty_eth=0, perp_entry_px=NULL, + option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?, + option_entry_px=?, entry_index_px=?, initial_premium=?, status='half_open' + WHERE id=1""", + ( + group_id, + perp_side, + option_inst_id, + option_side, + opt_qty, + opt_contracts, + of_px, + entry_index_px, + initial_premium, + ), + ) + self.db._conn.commit() + + def repair_half_open(self) -> CloseResult: + """卖出 half_open 残留期权,清本地状态。""" + err = self._guard_live() + if err: + return CloseResult(ok=False, detail=err) + pos = self.current_position() + if pos.get("status") != "half_open": + return CloseResult(ok=False, detail="非 half_open 状态") + group_id = str(pos.get("group_id") or "") + option_inst_id = str(pos.get("option_inst_id") or "") + opt_contracts = float(pos.get("option_qty_contracts") or 0) + opt_qty = float(pos.get("option_qty_eth") or 0) + if not option_inst_id or opt_contracts <= 0: + return CloseResult(ok=False, detail="half_open 缺期权合约信息") + client = self._client() + try: + opt_live = client.place_market( + inst_id=option_inst_id, + side="sell", + sz=str(int(round(opt_contracts))), + td_mode="cash", + reduce_only=True, + ) + except Exception as e: + return CloseResult(ok=False, detail=f"half_open 平期权失败: {e}") + of_px = float(opt_live.avg_px) + of_fee = float(opt_live.fee) + of_notional = of_px * opt_qty + opt_entry = float(pos.get("option_entry_px") or of_px) + self.ledger.apply_cash( + of_notional - of_fee, + kind="close_option", + group_id=group_id or None, + note="LIVE repair half_open", + allow_negative=True, + ) + now = int(time.time() * 1000) + with self.db._lock: + if group_id: + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "option", + "close", + "flat", + option_inst_id, + opt_qty, + opt_contracts, + of_px, + of_px, + of_fee, + 0.0, + of_notional, + now, + "LIVE", + ), + ) + opt_pnl = (of_px - opt_entry) * opt_qty - of_fee + self.db._conn.execute( + """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, note=? + WHERE group_id=?""", + ( + "closed", + now, + "half_open_repair", + float(opt_pnl), + "repaired half_open", + group_id, + ), + ) + self.db._conn.execute( + """UPDATE positions SET + group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL, + option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0, + option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat' + WHERE id=1""" + ) + self.db._conn.commit() + return CloseResult( + ok=True, + detail="half_open_repaired", + data={"group_id": group_id, "exec_mode": "LIVE"}, + ) + def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult: err = self._guard_live() if err: @@ -245,7 +452,10 @@ class OkxLiveExecutor(Matcher): s = get_settings() pos = self.current_position() - if pos.get("status") != "open" or not pos.get("group_id"): + st = str(pos.get("status") or "") + if st == "half_open": + return self.repair_half_open() + if st not in ("open", "option_closed_perp_pending") or not pos.get("group_id"): return CloseResult(ok=False, detail="无持仓可平") group_id = str(pos["group_id"]) @@ -258,6 +468,7 @@ class OkxLiveExecutor(Matcher): client = self._client() is_expiry = reason == "expiry" fee_rate = self._fee_rate() + pending_perp_only = st == "option_closed_perp_pending" sess = get_session() snap = sess.snapshot() @@ -274,7 +485,24 @@ class OkxLiveExecutor(Matcher): of_slip = 0.0 of_notional = 0.0 - if is_expiry: + if pending_perp_only: + # 期权已在上次成交并入账;只读上次平期权 fill + prev = self.db.fetchone( + """SELECT fill_px, fee, notional, slip FROM fills + WHERE group_id=? AND leg='option' AND action='close' + ORDER BY id DESC LIMIT 1""", + (group_id,), + ) + if prev is None: + return CloseResult( + ok=False, + detail="option_closed_perp_pending 缺期权平仓记录,请人工核对", + ) + of_px = float(prev["fill_px"]) + of_fee = float(prev["fee"] or 0) + of_notional = float(prev["notional"] or (of_px * opt_qty)) + of_slip = float(prev["slip"] or 0) + elif is_expiry: if intrinsic is None: return CloseResult(ok=False, detail="到期结算失败:缺行权价或标的价") of = option_expiry_settle( @@ -302,6 +530,20 @@ class OkxLiveExecutor(Matcher): ) return CloseResult(ok=False, detail=f"实盘平期权失败: {e}") + # 期权已平:立刻落 pending,避免永续失败后重试再卖期权 + self._mark_option_closed_perp_pending( + group_id=group_id, + option_inst_id=option_inst_id, + opt_qty=opt_qty, + opt_contracts=opt_contracts, + of_px=of_px, + of_fee=of_fee, + of_notional=of_notional, + of_slip=of_slip, + reason=reason, + ) + pending_perp_only = True + try: ct_val = client.get_ct_val(s.perp_inst_id, inst_type="SWAP") perp_sz = max(1, int(round(perp_qty / ct_val))) @@ -320,35 +562,55 @@ class OkxLiveExecutor(Matcher): pf_px = float(perp_live.avg_px) pf_fee = float(perp_live.fee) except Exception as e: - return CloseResult(ok=False, detail=f"期权已平但永续平仓失败: {e}") + return CloseResult( + ok=False, + detail=f"期权已平,永续待平(option_closed_perp_pending): {e}", + ) - opt_entry = float(pos["option_entry_px"]) - perp_entry = float(pos["perp_entry_px"]) - opt_pnl = (of_px - opt_entry) * opt_qty - if perp_side == "long": - perp_pnl = (pf_px - perp_entry) * perp_qty - else: - perp_pnl = (perp_entry - pf_px) * perp_qty + return self._finalize_dual_close( + pos=pos, + group_id=group_id, + option_inst_id=option_inst_id, + opt_qty=opt_qty, + opt_contracts=opt_contracts, + of_px=of_px, + of_fee=of_fee, + of_slip=of_slip, + of_notional=of_notional, + pf_px=pf_px, + pf_fee=pf_fee, + reason=reason, + option_fill_already_written=( + st == "option_closed_perp_pending" + or (pending_perp_only and not is_expiry) + ), + skip_option_cash=( + st == "option_closed_perp_pending" + or (pending_perp_only and not is_expiry) + ), + ) + def _mark_option_closed_perp_pending( + self, + *, + group_id: str, + option_inst_id: str, + opt_qty: float, + opt_contracts: float, + of_px: float, + of_fee: float, + of_notional: float, + of_slip: float, + reason: str, + ) -> None: self.ledger.apply_cash( of_notional - of_fee, kind="close_option", group_id=group_id, - note=f"LIVE close option {reason}", + note=f"LIVE close option pending perp {reason}", + allow_negative=True, ) - self.ledger.apply_cash( - perp_pnl - pf_fee, - kind="close_perp", - group_id=group_id, - note=f"LIVE close perp {reason}", - ) - now = int(time.time() * 1000) - g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,)) - fees = float((g["fees"] if g else 0) or 0) + of_fee + pf_fee - slip = float((g["slip_cost"] if g else 0) or 0) + of_slip - from ..sim.pnl import summarize_fills_pnl - with self.db._lock: self.db._conn.execute( """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, @@ -371,6 +633,92 @@ class OkxLiveExecutor(Matcher): "LIVE", ), ) + self.db._conn.execute( + "UPDATE positions SET status='option_closed_perp_pending' WHERE id=1" + ) + self.db._conn.execute( + "UPDATE groups SET fees=COALESCE(fees,0)+?, note=? WHERE group_id=?", + (of_fee, f"option_closed_perp_pending:{reason}", group_id), + ) + self.db._conn.commit() + + def _finalize_dual_close( + self, + *, + pos: dict, + group_id: str, + option_inst_id: str, + opt_qty: float, + opt_contracts: float, + of_px: float, + of_fee: float, + of_slip: float, + of_notional: float, + pf_px: float, + pf_fee: float, + reason: str, + option_fill_already_written: bool, + skip_option_cash: bool, + ) -> CloseResult: + s = get_settings() + perp_side = str(pos["perp_side"]) + perp_qty = float(pos["perp_qty_eth"]) + opt_entry = float(pos["option_entry_px"]) + perp_entry = float(pos["perp_entry_px"] or pf_px) + opt_pnl = (of_px - opt_entry) * opt_qty + if perp_side == "long": + perp_pnl = (pf_px - perp_entry) * perp_qty + else: + perp_pnl = (perp_entry - pf_px) * perp_qty + + if not skip_option_cash: + self.ledger.apply_cash( + of_notional - of_fee, + kind="close_option", + group_id=group_id, + note=f"LIVE close option {reason}", + allow_negative=True, + ) + self.ledger.apply_cash( + perp_pnl - pf_fee, + kind="close_perp", + group_id=group_id, + note=f"LIVE close perp {reason}", + allow_negative=True, + ) + + now = int(time.time() * 1000) + g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,)) + base_fees = float((g["fees"] if g else 0) or 0) + fees = base_fees + (0.0 if skip_option_cash else of_fee) + pf_fee + slip = float((g["slip_cost"] if g else 0) or 0) + ( + 0.0 if option_fill_already_written else of_slip + ) + from ..sim.pnl import summarize_fills_pnl + + with self.db._lock: + if not option_fill_already_written: + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "option", + "close", + "flat", + option_inst_id, + opt_qty, + opt_contracts, + of_px, + of_px, + of_fee, + of_slip, + of_notional, + now, + "LIVE", + ), + ) self.db._conn.execute( """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) @@ -419,18 +767,23 @@ class OkxLiveExecutor(Matcher): data={"group_id": group_id, "reason": reason, "net_pnl": net, "exec_mode": "LIVE"}, ) - def close_perp_abandon_option(self, *, reason: str = "target_perp_only") -> CloseResult: + def close_perp_abandon_option( + self, *, reason: str = "target_perp_only", require_deep_otm: bool = True + ) -> CloseResult: err = self._guard_live() if err: return CloseResult(ok=False, detail=err) - # 先校验远虚,再实盘只平永续,其余写入复用父类逻辑的简化版: - if not self.option_is_deep_otm(): + if require_deep_otm and not self.option_is_deep_otm(): return CloseResult(ok=False, detail="期权非远虚,应走双腿全平") s = get_settings() pos = self.current_position() - if pos.get("status") != "open" or not pos.get("group_id"): + st = str(pos.get("status") or "") + if st not in ("open", "option_closed_perp_pending") or not pos.get("group_id"): return CloseResult(ok=False, detail="无持仓可平") + # 若期权已平只剩永续,走 close_group 续平即可 + if st == "option_closed_perp_pending": + return self.close_group(reason=reason, bypass_liquidity=True) group_id = str(pos["group_id"]) perp_side = str(pos["perp_side"]) @@ -567,17 +920,6 @@ class OkxLiveExecutor(Matcher): ) -class BinanceLiveStub(Matcher): - def open_group(self, **kwargs: Any) -> OpenResult: # type: ignore[override] - return OpenResult(ok=False, detail="币安实盘下单尚未接入,请使用 OKX 或切回 SIM") - - def close_group(self, **kwargs: Any) -> CloseResult: # type: ignore[override] - return CloseResult(ok=False, detail="币安实盘下单尚未接入,请使用 OKX 或切回 SIM") - - def close_perp_abandon_option(self, **kwargs: Any) -> CloseResult: # type: ignore[override] - return CloseResult(ok=False, detail="币安实盘下单尚未接入,请使用 OKX 或切回 SIM") - - def get_executor(db=None) -> Matcher: """按 MODE + 交易所返回执行器。""" from ..models.db import get_db @@ -588,5 +930,7 @@ def get_executor(db=None) -> Matcher: return Matcher(database) ex = load_runtime_settings().exchange if ex == "binance": - return BinanceLiveStub(database) + from .binance_executor import BinanceLiveExecutor + + return BinanceLiveExecutor(database) return OkxLiveExecutor(database) diff --git a/backend/app/live/okx_trade.py b/backend/app/live/okx_trade.py index 44dea29..b630440 100644 --- a/backend/app/live/okx_trade.py +++ b/backend/app/live/okx_trade.py @@ -15,6 +15,7 @@ import httpx from ..config import Settings, get_settings from ..exchange.okx.parse import safe_float +from .rate_limit import RateLimitError, get_throttle, parse_retry_after_header logger = logging.getLogger(__name__) @@ -41,6 +42,7 @@ class OkxTradeClient: headers={"Accept": "application/json", "User-Agent": "eth-hedge-live/0.1"}, ) self._ct_val_cache: dict[str, float] = {} + self._throttle = get_throttle("okx_trade", min_interval_sec=1.0) def close(self) -> None: self._client.close() @@ -70,6 +72,7 @@ class OkxTradeClient: def _request( self, method: str, path: str, body: dict[str, Any] | None = None ) -> list[dict[str, Any]]: + self._throttle.before_request() payload = "" if body is None else json.dumps(body, separators=(",", ":")) ts = self._ts() sign = self._sign(ts, method, path, payload) @@ -78,11 +81,31 @@ class OkxTradeClient: r = self._client.get(path, headers=headers) else: r = self._client.request(method.upper(), path, content=payload, headers=headers) - r.raise_for_status() + if r.status_code in (418, 429): + ra = parse_retry_after_header(r.headers) + self._throttle.mark_http(r.status_code, ra) + raise RateLimitError( + f"OKX HTTP {r.status_code}: {r.text[:200]}", + retry_after=self._throttle.remaining_cooldown(), + ) + try: + r.raise_for_status() + except httpx.HTTPStatusError as e: + raise RuntimeError(f"OKX HTTP {r.status_code}: {r.text[:300]}") from e data = r.json() - if str(data.get("code")) != "0": + code = str(data.get("code") or "") + msg = str(data.get("msg") or "") + # OKX 业务层频率类错误 + if code != "0": + low = f"{code} {msg}".lower() + if code in ("50011", "50061") or "too many" in low or "频率" in msg: + self._throttle.mark_seconds(20.0) + raise RateLimitError( + f"OKX trade rate-limited code={code} msg={msg}", + retry_after=self._throttle.remaining_cooldown(), + ) raise RuntimeError( - f"OKX trade error code={data.get('code')} msg={data.get('msg')} data={data.get('data')}" + f"OKX trade error code={code} msg={msg} data={data.get('data')}" ) rows = data.get("data") or [] return [x for x in rows if isinstance(x, dict)] diff --git a/backend/app/live/rate_limit.py b/backend/app/live/rate_limit.py new file mode 100644 index 0000000..e17921d --- /dev/null +++ b/backend/app/live/rate_limit.py @@ -0,0 +1,196 @@ +"""实盘交易限流:私有 REST 冷却 + 失败退避。""" + +from __future__ import annotations + +import logging +import threading +import time +from typing import Any + +logger = logging.getLogger(__name__) + +_DEFAULT_429_SEC = 20.0 +_DEFAULT_418_SEC = 120.0 +_INTERVAL_MIN = 0.2 +_INTERVAL_MAX = 30.0 + + +def resolve_live_order_interval_sec() -> float: + """读取前端可配的 LIVE 下单最小间隔(秒),默认 1。""" + try: + from ..config import get_settings + from ..models.db import get_db + + s = get_settings() + default = float(s.live_order_interval_sec) + raw = get_db().get_setting("live_order_interval_sec", str(default)) + v = float(raw if raw not in (None, "") else default) + if v != v: # NaN + return 1.0 + return max(_INTERVAL_MIN, min(_INTERVAL_MAX, v)) + except Exception: + return 1.0 + + +class RateLimitError(RuntimeError): + """处于限流/冷却中,调用方应退避,勿立即重试下单。""" + + def __init__(self, message: str, *, retry_after: float = 0.0) -> None: + super().__init__(message) + self.retry_after = float(retry_after) + + +class TradeThrottle: + """按通道节流:最小间隔 + 418/429 冷却。""" + + def __init__( + self, + name: str, + *, + min_interval_sec: float = 1.0, + cooldown_429_sec: float = _DEFAULT_429_SEC, + cooldown_418_sec: float = _DEFAULT_418_SEC, + ) -> None: + self.name = name + self.min_interval_sec = float(min_interval_sec) + self.cooldown_429_sec = float(cooldown_429_sec) + self.cooldown_418_sec = float(cooldown_418_sec) + self._lock = threading.Lock() + self._last_at = 0.0 + self._cool_until = 0.0 + + def remaining_cooldown(self) -> float: + with self._lock: + return max(0.0, self._cool_until - time.monotonic()) + + def before_request(self) -> None: + """请求前调用:冷却中抛 RateLimitError;否则等待最小间隔(可读设置)。""" + interval = resolve_live_order_interval_sec() + with self._lock: + self.min_interval_sec = interval + now = time.monotonic() + if now < self._cool_until: + left = self._cool_until - now + raise RateLimitError( + f"{self.name} rate-limit cooldown {left:.1f}s", + retry_after=left, + ) + gap = now - self._last_at + wait = interval - gap + if wait > 0: + time.sleep(wait) + with self._lock: + self._last_at = time.monotonic() + + def mark_http(self, status_code: int, retry_after: float | None = None) -> None: + if status_code not in (418, 429): + return + if status_code == 418: + wait = self.cooldown_418_sec + else: + wait = float(retry_after) if retry_after and retry_after > 0 else self.cooldown_429_sec + wait = max(wait, self.cooldown_429_sec) + with self._lock: + self._cool_until = time.monotonic() + wait + logger.warning("%s HTTP %s → cooldown %.0fs", self.name, status_code, wait) + + def mark_seconds(self, seconds: float) -> None: + wait = max(1.0, float(seconds)) + with self._lock: + self._cool_until = max(self._cool_until, time.monotonic() + wait) + logger.warning("%s cooldown %.0fs (manual)", self.name, wait) + + +_THROTTLES: dict[str, TradeThrottle] = {} +_THROTTLES_LOCK = threading.Lock() + + +def get_throttle(name: str, **kwargs: Any) -> TradeThrottle: + with _THROTTLES_LOCK: + t = _THROTTLES.get(name) + if t is None: + t = TradeThrottle(name, **kwargs) + _THROTTLES[name] = t + return t + + +def is_rate_limit_error(exc: BaseException | str) -> bool: + if isinstance(exc, RateLimitError): + return True + text = str(exc).lower() + needles = ( + "429", + "418", + "rate limit", + "rate-limit", + "ratelimit", + "too many request", + "cooldown", + "banned", + "frequency", + "请求过于频繁", + "超出频率", + ) + return any(n in text for n in needles) + + +def parse_retry_after_header(headers: Any) -> float | None: + try: + raw = headers.get("Retry-After") if headers is not None else None + if raw is None: + return None + return float(raw) + except (TypeError, ValueError): + return None + + +class LiveRetryGate: + """引擎侧失败退避:避免 half_open / pending / liquidity 每秒砸单。""" + + def __init__( + self, + *, + base_sec: float = 2.0, + max_sec: float = 60.0, + rate_limit_min_sec: float = 20.0, + trip_after: int = 12, + trip_cooldown_sec: float = 180.0, + ) -> None: + self.base_sec = float(base_sec) + self.max_sec = float(max_sec) + self.rate_limit_min_sec = float(rate_limit_min_sec) + self.trip_after = int(trip_after) + self.trip_cooldown_sec = float(trip_cooldown_sec) + self._fails: dict[str, int] = {} + self._next_at: dict[str, float] = {} + + def allow(self, key: str) -> tuple[bool, float]: + """返回 (可否执行, 剩余等待秒)。""" + left = max(0.0, self._next_at.get(key, 0.0) - time.monotonic()) + return left <= 0.0, left + + def success(self, key: str) -> None: + self._fails.pop(key, None) + self._next_at.pop(key, None) + + def fail(self, key: str, *, rate_limited: bool = False) -> float: + n = int(self._fails.get(key, 0)) + 1 + self._fails[key] = n + if rate_limited: + delay = max(self.rate_limit_min_sec, self.rate_limit_min_sec * (1.5 ** min(n - 1, 4))) + delay = min(delay, 120.0) + elif n >= self.trip_after: + delay = self.trip_cooldown_sec + logger.error( + "live retry gate tripped key=%s fails=%s cooldown=%.0fs", + key, + n, + delay, + ) + else: + delay = min(self.max_sec, self.base_sec * (2 ** min(n - 1, 5))) + self._next_at[key] = time.monotonic() + delay + return delay + + def fails(self, key: str) -> int: + return int(self._fails.get(key, 0)) diff --git a/backend/app/models/db.py b/backend/app/models/db.py index e561c32..2eae6e9 100644 --- a/backend/app/models/db.py +++ b/backend/app/models/db.py @@ -196,6 +196,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), + "live_order_interval_sec": str(s.live_order_interval_sec), "skip_weekends": str(s.skip_weekends), "max_rounds": str(s.max_rounds), "leverage": str(s.leverage), diff --git a/backend/app/sim/ledger.py b/backend/app/sim/ledger.py index 6aca95b..e3c5e29 100644 --- a/backend/app/sim/ledger.py +++ b/backend/app/sim/ledger.py @@ -27,15 +27,19 @@ class Ledger: kind: str, group_id: str | None = None, note: str = "", + allow_negative: bool = False, ) -> float: - """amount>0 入账;amount<0 出账。返回余额。""" + """amount>0 入账;amount<0 出账。返回余额。 + + LIVE 实盘成交后本地账本仅作镜像,须 allow_negative=True,避免「交易所已成交、本地拒记」导致卡仓。 + """ now = int(time.time() * 1000) with self.db._lock: row = self.db._conn.execute("SELECT * FROM ledger_meta WHERE id=1").fetchone() assert row is not None equity = float(row["equity"]) + float(amount) available = float(row["available"]) + float(amount) - if available < -1e-9: + if not allow_negative and available < -1e-9: raise RuntimeError("可用资金不足") self.db._conn.execute( "UPDATE ledger_meta SET equity=?, available=?, updated_at_ms=? WHERE id=1", diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 1f1d3e9..38d091c 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -21,6 +21,11 @@ from .pricing import ( resolve_option_close_bid, ) +# 禁止新开仓的本地仓位状态(实盘防卡) +BLOCKING_STATUSES = frozenset( + {"open", "half_open", "option_closed_perp_pending"} +) + @dataclass(slots=True) class OpenResult: @@ -67,8 +72,15 @@ class Matcher: return dict(row) def has_open_position(self) -> bool: + """是否禁止新开:含 open / half_open / option_closed_perp_pending。""" pos = self.current_position() - return pos.get("status") == "open" and bool(pos.get("group_id")) + st = str(pos.get("status") or "") + if st not in BLOCKING_STATUSES: + return False + return bool(pos.get("group_id") or pos.get("option_inst_id")) + + def position_status(self) -> str: + return str(self.current_position().get("status") or "flat") def _liquidity_wait(self, group_id: str, detail: str) -> CloseResult: note = f"liquidity_wait:{int(time.time())}:{detail[:80]}" @@ -601,7 +613,9 @@ class Matcher: option_side=option_side, strike=float(strike), spot=float(spot) ) - def close_perp_abandon_option(self, *, reason: str = "target_perp_only") -> CloseResult: + def close_perp_abandon_option( + self, *, reason: str = "target_perp_only", require_deep_otm: bool = True + ) -> CloseResult: """ 目标平仓 B:只平永续,期权归档为到期残留(不再盯盘、不挡新开)。 """ @@ -622,7 +636,7 @@ class Matcher: spot = self._close_spot_px(snap) if strike is None or spot is None: return CloseResult(ok=False, detail="无法判断远虚:缺行权价或标的价") - if not is_deep_otm( + if require_deep_otm and not is_deep_otm( option_side=option_side, strike=float(strike), spot=float(spot) ): return CloseResult(ok=False, detail="期权非远虚,应走双腿全平") diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 3d62c14..4a6f989 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -12,6 +12,7 @@ from .session import get_session from ..models.db import get_db from ..sim.ledger import Ledger from ..live import get_executor +from ..live.rate_limit import LiveRetryGate, is_rate_limit_error from ..env_store import live_ready from .clock import can_open_new, window_key from .exits import check_expiry_close, check_exits, resolve_exit_target @@ -27,11 +28,39 @@ class StrategyEngine: self.matcher = get_executor(self.db) self._task: asyncio.Task[None] | None = None self._lock = asyncio.Lock() + self._retry_gate = LiveRetryGate() + self._extra_sleep_sec = 0.0 def refresh_executor(self) -> None: """MODE 变更后刷新执行器。""" self.matcher = get_executor(self.db) + def _gate_key(self, kind: str) -> str: + pos = self.matcher.current_position() + gid = str(pos.get("group_id") or "none") + return f"{kind}:{gid}" + + def _note_retry_result(self, kind: str, *, ok: bool, detail: str = "") -> None: + key = self._gate_key(kind) + if ok: + self._retry_gate.success(key) + return + rl = is_rate_limit_error(detail) + delay = self._retry_gate.fail(key, rate_limited=rl) + if rl: + self._extra_sleep_sec = max(self._extra_sleep_sec, min(delay, 60.0)) + logger.warning( + "live retry backoff kind=%s fails=%s delay=%.1fs rate_limited=%s detail=%s", + kind, + self._retry_gate.fails(key), + delay, + rl, + (detail or "")[:160], + ) + + def _retry_allowed(self, kind: str) -> tuple[bool, float]: + return self._retry_gate.allow(self._gate_key(kind)) + def state(self) -> dict[str, Any]: row = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1") assert row is not None @@ -139,13 +168,38 @@ class StrategyEngine: detail = "flat" ok = True pos = self.matcher.current_position() - if pos.get("status") == "open": - r = self.matcher.close_group(reason="emergency", bypass_liquidity=True) + st = str(pos.get("status") or "flat") + + if st == "half_open": + repair = getattr(self.matcher, "repair_half_open", None) + if callable(repair): + r = repair() + else: + r = self.matcher.close_group(reason="emergency", bypass_liquidity=True) ok = r.ok detail = r.detail close_data = r.data if r.ok: self._after_close() + elif st in ("open", "option_closed_perp_pending"): + # A:双腿(或续平永续) + r = self.matcher.close_group(reason="emergency", bypass_liquidity=True) + if not r.ok and st == "open": + # B:砸不出期权时强制只平永续(不要求远虚) + abandon = getattr(self.matcher, "close_perp_abandon_option", None) + if callable(abandon): + try: + r2 = abandon(reason="emergency_perp", require_deep_otm=False) + except TypeError: + r2 = abandon(reason="emergency_perp") + if r2.ok: + r = r2 + ok = r.ok + detail = r.detail + close_data = r.data + if r.ok: + self._after_close() + residuals = self.matcher.settle_all_residuals_now() return { "close": { @@ -201,23 +255,44 @@ class StrategyEngine: bypass_liquidity: bool, pending_close: bool, abandon_if_deep_otm: bool = False, + retry_kind: str | None = None, ) -> None: + kind = retry_kind or ( + "perp_pending" + if reason == "perp_pending_retry" + else ("liquidity" if pending_close or not bypass_liquidity else "close") + ) + allowed, left = self._retry_allowed(kind) + if not allowed: + self._set_state( + phase="liquidity_wait" if kind == "liquidity" else "closing", + last_error=f"限流/失败退避中,{left:.0f}s 后再试 ({kind})", + ) + return + if not pending_close: self._set_state(phase="closing", last_error=None) # 目标平仓 B:远虚 → 只平永续,期权归档 if abandon_if_deep_otm and reason != "expiry" and self.matcher.option_is_deep_otm(): - r = await asyncio.to_thread( - self.matcher.close_perp_abandon_option, - reason="target_perp_only", - ) + abandon = self.matcher.close_perp_abandon_option + try: + r = await asyncio.to_thread( + abandon, + reason="target_perp_only", + require_deep_otm=True, + ) + except TypeError: + r = await asyncio.to_thread(abandon, reason="target_perp_only") if r.ok: + self._note_retry_result(kind, ok=True) self._after_close() self._set_state( last_error=None, phase="resting", ) else: + self._note_retry_result(kind, ok=False, detail=r.detail) self._set_state(phase="closing", last_error=r.detail) return @@ -227,6 +302,7 @@ class StrategyEngine: bypass_liquidity=bypass_liquidity, ) if r.ok: + self._note_retry_result(kind, ok=True) self._after_close() elif r.liquidity_wait and not bypass_liquidity: # 等待期间若已变成远虚,下一 tick 走归档 @@ -236,10 +312,13 @@ class StrategyEngine: reason="target_perp_only", ) if r2.ok: + self._note_retry_result(kind, ok=True) self._after_close() return + self._note_retry_result("liquidity", ok=False, detail=r.detail) self._set_state(phase="liquidity_wait", last_error=r.detail) else: + self._note_retry_result(kind, ok=False, detail=r.detail) self._set_state(phase="closing", last_error=r.detail) async def _settle_residuals(self) -> None: @@ -249,7 +328,7 @@ class StrategyEngine: """若持仓已到期则强制全平。返回是否触发到期平仓。""" await self._settle_residuals() pos = self.matcher.current_position() - if pos.get("status") != "open": + if pos.get("status") not in ("open", "option_closed_perp_pending"): return False upl = self.matcher.unrealized() expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl)) @@ -263,6 +342,7 @@ class StrategyEngine: bypass_liquidity=True, pending_close=pending, abandon_if_deep_otm=False, + retry_kind="expiry", ) return True @@ -288,15 +368,16 @@ class StrategyEngine: raise except Exception as e: err = str(e) - # 限流时勿刷屏;拉长休眠给 eapi 冷却 - if "418" in err or "429" in err or "cooldown" in err.lower(): + if is_rate_limit_error(err): logger.warning("strategy tick rate-limited: %s", err[:200]) - self._set_state(last_error="币安期权接口限流,稍后自动重试") - await asyncio.sleep(15) + self._set_state(last_error="交易/行情接口限流,稍后自动重试") + await asyncio.sleep(20) continue logger.exception("strategy tick failed") self._set_state(last_error=err) - await asyncio.sleep(1) + sleep_for = 1.0 + max(0.0, self._extra_sleep_sec) + self._extra_sleep_sec = 0.0 + await asyncio.sleep(min(sleep_for, 60.0)) async def _tick_async(self) -> None: # 残留期权到期结算(与活跃组隔离,不挡开仓) @@ -319,9 +400,42 @@ class StrategyEngine: "premium_exit_multiple", s.premium_exit_multiple ) pos = self.matcher.current_position() + st_pos = str(pos.get("status") or "flat") + + # 实盘半仓修复:禁止新开;失败指数退避,避免每秒砸期权 + if st_pos == "half_open": + allowed, left = self._retry_allowed("half_open") + if not allowed: + self._set_state( + phase="closing", + last_error=f"half_open 修复退避中,{left:.0f}s 后再试", + ) + return + repair = getattr(self.matcher, "repair_half_open", None) + if callable(repair): + r = await asyncio.to_thread(repair) + if r.ok: + self._note_retry_result("half_open", ok=True) + self._after_close() + self._set_state(phase="resting", last_error=None) + else: + self._note_retry_result("half_open", ok=False, detail=r.detail) + self._set_state(phase="closing", last_error=r.detail) + return + + # 期权已平、永续待平:只续平永续(带退避) + if st_pos == "option_closed_perp_pending": + await self._close_open_position( + reason="perp_pending_retry", + bypass_liquidity=True, + pending_close=True, + abandon_if_deep_otm=False, + retry_kind="perp_pending", + ) + return # 有活跃持仓:只盯当前组平仓;残留期权不在此扫描 - if pos.get("status") == "open": + if st_pos == "open": upl = self.matcher.unrealized() expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl)) decision = check_exits( @@ -337,16 +451,19 @@ class StrategyEngine: reason = "expiry" bypass = True abandon = False + rkind = "expiry" else: reason = decision.reason or "liquidity_retry" bypass = False # 目标达标(或流动性等待重试)时:远虚走只平永续 abandon = bool(decision.should_close or pending_close) + rkind = "liquidity" if pending_close else "close" await self._close_open_position( reason=reason, bypass_liquidity=bypass, pending_close=pending_close, abandon_if_deep_otm=abandon, + retry_kind=rkind, ) else: self._set_state(phase="open", last_error=None) diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index ac8c8c4..df52414 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -30,9 +30,15 @@ _session: StrategySession | None = None def _has_open_position() -> bool: try: from ..models.db import get_db + from ..sim.matcher import BLOCKING_STATUSES - row = get_db().fetchone("SELECT status FROM positions WHERE id=1") - return bool(row and row["status"] == "open") + row = get_db().fetchone("SELECT status, group_id, option_inst_id FROM positions WHERE id=1") + if not row: + return False + st = str(row["status"] or "") + if st not in BLOCKING_STATUSES: + return False + return bool(row["group_id"] or row["option_inst_id"]) except Exception: return False @@ -45,7 +51,10 @@ def _held_option_inst_id() -> str | None: row = get_db().fetchone( "SELECT status, option_inst_id FROM positions WHERE id=1" ) - if not row or row["status"] != "open": + if not row or row["status"] not in ("open", "half_open", "option_closed_perp_pending"): + return None + # 期权已平待平永续:不再钉期权盘口 + if row["status"] == "option_closed_perp_pending": return None inst = str(row["option_inst_id"] or "").strip() return inst or None diff --git a/backend/tests/test_live_rate_limit.py b/backend/tests/test_live_rate_limit.py new file mode 100644 index 0000000..ee6d774 --- /dev/null +++ b/backend/tests/test_live_rate_limit.py @@ -0,0 +1,59 @@ +"""实盘限流 / 退避单测。""" + +from __future__ import annotations + +import time + +from app.live.rate_limit import ( + LiveRetryGate, + RateLimitError, + TradeThrottle, + get_throttle, + is_rate_limit_error, +) + + +def test_is_rate_limit_error() -> None: + assert is_rate_limit_error("HTTP 429 too many") + assert is_rate_limit_error("binance eapi cooldown 12s") + assert is_rate_limit_error(RateLimitError("x", retry_after=5)) + assert not is_rate_limit_error("保证金不足") + + +def test_trade_throttle_cooldown() -> None: + t = TradeThrottle("ut_throttle", min_interval_sec=0.01, cooldown_429_sec=0.3) + t.before_request() + t.mark_http(429) + try: + t.before_request() + assert False, "expected RateLimitError" + except RateLimitError as e: + assert e.retry_after > 0 + time.sleep(0.35) + t.before_request() # 冷却结束后可继续 + + +def test_get_throttle_singleton() -> None: + a = get_throttle("ut_shared_x", min_interval_sec=0.01) + b = get_throttle("ut_shared_x") + assert a is b + + +def test_live_retry_gate_backoff() -> None: + g = LiveRetryGate(base_sec=0.05, max_sec=0.2, rate_limit_min_sec=0.1, trip_after=100) + assert g.allow("k")[0] is True + d1 = g.fail("k") + assert d1 >= 0.05 + ok, left = g.allow("k") + assert ok is False + assert left > 0 + time.sleep(d1 + 0.02) + assert g.allow("k")[0] is True + g.success("k") + assert g.fails("k") == 0 + + +def test_live_retry_gate_rate_limited_longer() -> None: + g = LiveRetryGate(base_sec=0.01, rate_limit_min_sec=0.2) + d = g.fail("rl", rate_limited=True) + assert d >= 0.2 diff --git a/backend/tests/test_live_recovery.py b/backend/tests/test_live_recovery.py new file mode 100644 index 0000000..30a6840 --- /dev/null +++ b/backend/tests/test_live_recovery.py @@ -0,0 +1,69 @@ +"""实盘防卡状态:half_open / option_closed_perp_pending。""" + +from __future__ import annotations + +from app.sim.ledger import Ledger +from app.sim.matcher import BLOCKING_STATUSES, Matcher + + +def test_blocking_statuses_include_repair_states() -> None: + assert "half_open" in BLOCKING_STATUSES + assert "option_closed_perp_pending" in BLOCKING_STATUSES + assert "open" in BLOCKING_STATUSES + + +def test_has_open_position_blocks_half_open(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("MODE", "SIM") + from app.models.db import Database + + db = Database(tmp_path / "t.db") + m = Matcher(db) + assert m.has_open_position() is False + + with db._lock: + db._conn.execute( + """UPDATE positions SET + group_id=?, option_inst_id=?, option_side=?, option_qty_eth=?, + option_qty_contracts=?, option_entry_px=?, status=? + WHERE id=1""", + ("G-test", "ETH-OPT", "call", 2.0, 200.0, 10.0, "half_open"), + ) + db._conn.commit() + + assert m.has_open_position() is True + assert m.position_status() == "half_open" + + with db._lock: + db._conn.execute( + "UPDATE positions SET status='option_closed_perp_pending' WHERE id=1" + ) + db._conn.commit() + assert m.has_open_position() is True + + with db._lock: + db._conn.execute( + """UPDATE positions SET + group_id=NULL, option_inst_id=NULL, status='flat' WHERE id=1""" + ) + db._conn.commit() + assert m.has_open_position() is False + db.close() + + +def test_ledger_allow_negative(tmp_path) -> None: + from app.models.db import Database + + db = Database(tmp_path / "l.db") + ledger = Ledger(db) + # 掏空 + snap = ledger.snapshot() + ledger.apply_cash(-snap["available"], kind="drain", note="drain") + try: + ledger.apply_cash(-1.0, kind="fail", note="should fail") + assert False, "expected RuntimeError" + except RuntimeError: + pass + # LIVE 镜像允许透支 + bal = ledger.apply_cash(-1.0, kind="live", note="ok", allow_negative=True) + assert bal < 0 + db.close() diff --git a/backend/tests/test_runtime_mode.py b/backend/tests/test_runtime_mode.py index 069f396..ffa87f7 100644 --- a/backend/tests/test_runtime_mode.py +++ b/backend/tests/test_runtime_mode.py @@ -49,22 +49,22 @@ def test_live_ready_okx_missing_keys(monkeypatch) -> None: assert "OKX" in reason -def test_live_ready_binance_stub(monkeypatch) -> None: +def test_live_ready_binance_ok(monkeypatch) -> None: import app.env_store as es class S: mode = "LIVE" is_sim = False - okx_api_key = "k" - okx_api_secret = "s" - okx_api_passphrase = "p" + okx_api_key = "" + okx_api_secret = "" + okx_api_passphrase = "" binance_api_key = "bk" binance_api_secret = "bs" monkeypatch.setattr(es, "get_settings", lambda: S()) ok, reason = live_ready(exchange="binance") - assert ok is False - assert "尚未接入" in reason + assert ok is True + assert reason == "ok" def test_okx_keys_configured(monkeypatch) -> None: diff --git a/docs/OKX实盘策略说明.md b/docs/OKX实盘策略说明.md new file mode 100644 index 0000000..ea76476 --- /dev/null +++ b/docs/OKX实盘策略说明.md @@ -0,0 +1,121 @@ +# OKX 实盘策略说明 + +> 专用于 **OKX** LIVE。通用对冲逻辑见 [策略说明](./策略说明.md);币安见 [币安实盘策略说明](./币安实盘策略说明.md)。 +> **标准仓**:永续 **1 ETH** + 期权 **2 ETH** 名义(倍数 `k` 可缩放)。 +> 更新:2026-07-26 + +--- + +## 1. 一句话 + +ATM **期权买方** + **反向永续**;净利达标兑现,未达标拖到期。OKX 上期权多走 **USDC 保证金族**(`ETH-USD_UM`),账户侧常需 **USDT ↔ USDC** 换汇,与币安「统一 USDT」体验不同。 + +--- + +## 2. 合约与资金 + +| 腿 | 合约(默认) | 计价 / 保证金 | 说明 | +|----|--------------|---------------|------| +| 永续 | `ETH-USDT-SWAP` | **USDT** | 张数:名义 ETH ÷ `ctVal`(常见 0.01 ETH/张) | +| 期权 | `ETH-USD_UM-YYMMDD-K-C/P` | **USDC**(偏 USDC 保证金) | 张数:名义 ETH ÷ `ctMult`(默认 0.01) | + +### 2.1 USDT ↔ USDC(必做准备) + +- 权利金、期权保证金以 **USDC** 为主;永续盈亏与保证金以 **USDT** 为主。 +- 开仓前请保证: + - **USDT**:够永续保证金 + 缓冲; + - **USDC**:够期权权利金(约 15–60U 量级 ×k,视行情)+ 缓冲。 +- 可用 OKX 闪兑 / 兑换把 USDT 换成 USDC(或反向)。**SIM 不模拟换汇**;实盘缺 USDC 会直接下单失败。 +- 建议日常维持两侧都有余量,避免「有 USDT 却开不了期权」。 + +### 2.2 账户模式建议 + +| 项 | 建议 | +|----|------| +| 永续 | 全仓 `cross`;双向持仓(hedge)与软件 `posSide` 对齐 | +| 期权 | **买卖模式 / cash**(买方付权利金);尽量 **逐仓/独立**,便于远虚残留不挡下一组 | +| API | Key + Secret + **Passphrase**;IP 白名单;交易权限最小化 | + +--- + +## 3. 标准仓与缩放 + +与通用说明相同:`永续 = 1×k ETH`,`期权 = 2×k ETH`,净利目标 ≈ `15×k` USDT。 +试跑建议 **k=0.1**(0.1 + 0.2)。 + +--- + +## 4. 开仓机制(OKX) + +``` +选 ATM → 先市价买期权(tdMode=cash)→ 再市价开永续(tdMode=cross + posSide) +永续失败 → 立刻市价卖掉期权回滚 +``` + +| 步骤 | OKX 行为 | +|------|----------| +| 1 期权 | `POST /api/v5/trade/order`,`ordType=market`,`side=buy`,`sz`=张 | +| 2 永续 | 同上;多=`buy`+`posSide=long`,空=`sell`+`posSide=short` | +| 失败回滚 | 期权 `side=sell` + `reduceOnly` | + +**与币安差异**: + +- 需 **Passphrase**;期权与永续 **币种可能不同**(USDC vs USDT)。 +- 永续按 **张** 下单(÷ ctVal),不是直接填 ETH 数量字符串(软件内部换算)。 +- 期权 `tdMode=cash`;若账户改成其他模式,需改配置或改代码适配。 + +选向规则(ATM 相对现价)与通用策略说明一致。 + +--- + +## 5. 平仓机制(OKX) + +两大类与 SIM 相同:**目标平仓(A 双腿 / B 只平永续)**、**到期平仓**。 + +| 路径 | OKX 动作 | +|------|----------| +| **A 双腿全平** | 先市价卖期权(cash)→ 再市价平永续(reduceOnly + 反向 posSide) | +| **B 远虚** | 只平永续;期权留账户到期,按 **内在价值** 结算;残留不挡新开 | +| **到期** | 期权一般由交易所结算内在价值,软件记账;若永续仍在则市价平掉 | +| **紧急** | 尽量双腿市价;可放宽流动性等待 | + +流动性闸门(仅 A):买一深度、买一/标记偏差 ≤30%——实盘以能否成交为准,闸门主要用于避免垃圾价硬扫。 + +--- + +## 6. 监控与模式 + +| 状态 | 下方盘口 | +|------|----------| +| 有活跃仓 | 钉持仓行权价 | +| 空仓 / 仅残留 | 跟新 ATM(≥5 点切换) | + +设置页:交易所选 **OKX**,运行模式 **LIVE**,填 Key/Secret/Passphrase → 写入 `.env`。切 LIVE 须输入 `LIVE` 确认。 + +--- + +## 7. 风险(OKX 特有加重) + +| 风险 | 说明 | +|------|------| +| **换汇遗漏** | USDT 充足但 USDC 不足 → 期权拒单 | +| **模式不匹配** | 账户非 cash / 非双向持仓 → 下单参数报错 | +| **期权深度** | USDC 期权盘口偶发更薄,远虚更易走 B | +| **限流** | 独立 IP;退避重试 | + +--- + +## 8. 上线检查(OKX) + +1. SIM 在 OKX 行情下跑通开平。 +2. 账户 USDT + USDC 均到位;完成一次小额兑换演练。 +3. API 三件套 + LIVE 确认。 +4. k=0.1 试:开仓、目标平或到期、残留各至少一次。 + +--- + +## 9. 修订记录 + +| 日期 | 说明 | +|------|------| +| 2026-07-26 | 初稿:从通用实盘说明拆出 OKX;强调 USDT/USDC 与 cash/cross 开平仓 | diff --git a/docs/实盘策略说明.md b/docs/实盘策略说明.md index 38152da..88914bc 100644 --- a/docs/实盘策略说明.md +++ b/docs/实盘策略说明.md @@ -1,299 +1,39 @@ -# ETH 永续 + 期权对冲 · 实盘策略说明 +# ETH 永续 + 期权对冲 · 实盘说明索引 -> 逻辑对齐 [策略说明](./策略说明.md)(SIM 已验证路径),本文改写为 **实盘操盘口径**。 -> **标准仓位**:永续 **1 ETH** + 期权 **2 ETH** 名义;可按倍数放大 / 缩小。 -> 关联:[商业化与授权方案](./商业化与授权方案.md)、[开发方案](./开发方案.md) +> 通用规则见 [策略说明](./策略说明.md)。**开平仓、资金币种、API 按交易所分开写**,请直接打开对应文档。 > 更新:2026-07-26 --- -## 1. 策略一句话 +## 按交易所 -用 **ATM 期权买方** 吃波动弹性,用 **反向永续** 做对冲腿;净利达标就兑现,未达标则拖到期权到期结算。 -本质是 **概率样本**:波动日多轮,磨盘日认权利金磨损,**不做卖方、不叠活跃组、不赌每天固定次数**。 - -**实盘与 SIM 的边界**:规则相同;成交、深度、手续费、强平、拒单、限流以交易所为准。SIM 赚到的轮次 **不能** 直接外推为实盘收益。 - ---- - -## 2. 标准仓位与缩放 - -### 2.1 标准仓(1×) - -| 腿 | 标准数量 | 方向 | 成交 | -|----|----------|------|------| -| 永续 | **1 ETH** | 与期权反向(见 §3) | 市价 / IOC(吃对手价) | -| 期权 | **2 ETH** 名义 | **只买不卖** | 开仓吃卖一,平仓吃买一 | - -- 永续杠杆建议 **3×**(可调;越高强平越近)。 -- 同时最多 **1 组活跃仓**;远虚归档的残留期权(逐仓)不挡下一组。 -- **开仓顺序**:先期权 → 确认后再开永续;永续失败则立即平掉刚开的期权(不留半边)。 -- **平仓顺序(双腿全平)**:先期权 → 再永续。 - -### 2.2 缩放规则(放大 / 缩小) - -定义仓位倍数 **`k`**(相对标准仓): - -``` -永续名义 = 1 × k ETH -期权名义 = 2 × k ETH -``` - -| 倍数 k | 永续 | 期权名义 | 适用 | +| 交易所 | 文档 | 资金要点 | LIVE | |--------|------|----------|------| -| **0.1** | 0.1 | 0.2 | 实盘试跑 / 验证规则 | -| **0.25** | 0.25 | 0.5 | 小资金适应盘口 | -| **0.5** | 0.5 | 1.0 | 半仓 | -| **1.0** | **1** | **2** | **标准仓** | -| **2.0** | 2 | 4 | 放大(须资金与深度够) | - -**必须同比缩放(禁止只改一条腿)**: - -| 项目 | 规则 | -|------|------| -| 仓位 | 永续与期权名义始终保持 **1 : 2** | -| 净利目标(`fixed_usdt`) | `净利目标 ≈ 15 × k`(标准仓 15U) | -| 权利金倍数模式 | 倍数本身不变(仍按 `initial_premium × multiple`) | -| 资金占用 | 保证金 + 权利金 + 缓冲约按 **k** 近似线性放大 | -| 深度要求 | 期权买一/卖一须能覆盖 **本档名义**;k 越大越容易卡流动性 | - -> 例:k=0.1 → 永续 0.1 / 期权 0.2,目标约 **1.5U**;k=2 → 永续 2 / 期权 4,目标约 **30U**。 - -### 2.3 缩放时的注意 - -- **k 过小**:手续费占比升高,小目标更难赚;更适合「验证通不通」而非「赚多少」。 -- **k 过大**:期权盘口薄、冲击成本大;远虚时更难双腿平,更容易走「只平永续 + 期权到期」。 -- 改 k 须在 **无活跃仓** 时进行;残留期权可仍挂着,但不影响改下一组标准。 +| **OKX** | [OKX实盘策略说明](./OKX实盘策略说明.md) | 永续 **USDT** + 期权常 **USDC**,需 **USDT↔USDC** | 已接 | +| **币安** | [币安实盘策略说明](./币安实盘策略说明.md) | 永续与欧洲期权多为 **USDT**,一般无需换 USDC | 已接 | --- -## 3. 开仓方向(与 SIM 一致) +## 共用口径(两所相同) -行权价相对标的有偏离时,**先按 ATM 偏上/偏下选向**;贴平时再比 Call/Put 卖一。 - -| 条件 | 期权 | 永续 | -|------|------|------| -| ATM 行权价 **<** 标的 | 买 Call | 空永续 | -| ATM 行权价 **>** 标的 | 买 Put | 多永续 | -| ATM ≈ 标的,Call 卖一 > Put 卖一 | 买 Call | 空永续 | -| ATM ≈ 标的,Put 卖一 > Call 卖一 | 买 Put | 多永续 | -| ATM ≈ 标的且卖一相等 | 不开,继续等 | — | - ---- - -## 4. 开仓机制(实盘) - -### 4.1 流程 - -``` -策略运行中 - → 无活跃持仓且不在组间休息 - → 非周末跳过(若开启,上海时区) - → 选到期:剩余 ≥ min_option_hours(建议 ≥12h) - → 该到期 ATM(最接近标的) - → 可选:|ATM − 标的| ≤ max_atm_open_offset - → 期权杠杆 = 标的价 ÷ 卖一 ≥ min_option_leverage(建议 ≥100) - → 按 k 下单:先开期权 → 再开永续 - → 锁定 initial_premium = 期权成交价 × 期权名义(不含费) -``` - -### 4.2 实盘下单要点 - -| 项 | 建议 | +| 项 | 说明 | |----|------| -| 期权 | 限价吃卖一或交易所支持的市价/对手价;确认 **成交数量 = 计划名义** 后再开永续 | -| 永续 | 市价/IOC;失败则 **立刻市价平期权** | -| 部分成交 | 按实际成交名义对齐另一腿,或整组撤掉重来;禁止长期单腿敞口 | -| API / 限流 | 独立出口 IP;退避重试;断连时优先保仓可平 | - -### 4.3 节奏 - -| 规则 | 说明 | -|------|------| -| 周末 | 建议跳过新开;持仓仍可平、到期仍结算 | -| 组间休息 | 建议全平后休息约 **5 分钟** 再开下一组 | -| 轮次上限 | 不设硬顶;由行情与选约决定 | -| 活跃组 | 最多 1 组 | +| 标准仓 | 永续 **1 ETH** + 期权 **2 ETH** 名义;倍数 `k` 同比例缩放;净利目标 ≈ `15×k` | +| 试跑 | 建议 `k=0.1`(0.1 + 0.2) | +| 平仓类 | **目标平仓** A 双腿 / B 只平永续(远虚残留);**到期平仓** | +| 模式 | 设置页 SIM/LIVE;切 LIVE 输入 `LIVE`;密钥写入 `.env` | +| 同时仓 | 最多 1 组活跃;残留期权不挡新开 | --- -## 5. 平仓机制(实盘) +## 两所差异速览 -两大类:**① 目标平仓**;**② 到期平仓**。目标平仓再分 A/B。 +| 项 | OKX | 币安 | +|----|-----|------| +| 换汇 | 常需 USDT→USDC 才能付期权 | 通常只需 USDT | +| API | Key + Secret + Passphrase | Key + Secret | +| 期权 | `ETH-USD_UM`(V5) | 欧洲期权 eapi | +| 永续数量 | 张(÷ ctVal) | ETH 名义(fapi) | +| 开平顺序 | 先期权后永续;回滚卖期权 | 同序,分 eapi / fapi | -### 5.1 净盈利口径(盯盘) - -``` -净盈利 ≈ 永续浮盈 + 期权浮盈 − 预估平仓手续费 -期权浮盈 = 当前买一 × 数量 − 初始权利金 -``` - -主要看 **买一可平价**,不拿标记价当出场依据。 - -| 模式 | 标准仓(k=1)默认 | 缩放 | -|------|-------------------|------| -| `fixed_usdt` | 净利 ≥ **15 USDT** | ≥ **15 × k** | -| `premium_multiple` | 净利 ≥ 初始权利金 × 倍数 | 倍数不变 | - -### 5.2 目标 A · 双腿全平 - -- 净利达标,且期权 **非远虚**(仍有可平买一)。 -- 校验:买一深度覆盖平仓名义;买一相对标记偏差建议 ≤ **30%**。 -- 顺序:先平期权 → 再平永续。 -- 通不过 → 等待,不改组、不强开下一组。 - -### 5.3 目标 B · 只平永续 + 期权到期 - -- 净利达标,且期权 **远虚**(内在价值 ≈ 0;约 100× 杠杆期权在标的波动 ~1% 后常见)。 -- **只平永续**;期权留在逐仓账户等到期,按交易所 **内在价值** 结算。 -- 残留 **不挡** 下一组开仓;下一组只盯新活跃组。 -- 整组最终净利 = 已实现永续盈亏 + 期权到期结算 − 全部手续费。 - -### 5.4 到期平仓(未达标) - -- 不主动砍;持有到期权到期(OKX/币安欧式常见:UTC 08:00 = **上海 16:00**)。 -- 活跃组:期权按内在价值;若永续仍在则一并市价平掉。 -- 残留组:只结期权。 -- 策略暂停时仍应执行到期处理,避免拖过期。 - -### 5.5 紧急全平 - -- 活跃组尽量双腿市价平掉(可放宽流动性闸门)。 -- 残留期权:能平则平,否则等到期内在价值。 - -### 5.6 流程总览 - -``` -有活跃持仓 - ├─ 净利达标? - │ ├─ 是 · 远虚 → 只平永续,期权归档到期,可开下一组 - │ └─ 是 · 非远虚 → 双腿全平(流动性闸门)→ 失败则等 - └─ 否 → 持有到到期 → 内在价值结算(+ 平剩余永续) - -残留期权 - └─ 仅到期结算;不参与下一组盯盘 -``` - ---- - -## 6. 行情监控(实盘界面 / 风控台) - -| 状态 | 活跃持仓 | 残留列表 | 下方期权盘口 | -|------|----------|----------|--------------| -| 有活跃组 | 显示本组 | 可另有历史残留 | **钉持仓行权价**;浮盈亏只用该合约盘口 | -| 空仓 | 空 | 无 | **跟现价 ATM**(建议 \|ATM−标的\|≥5 点切换) | -| 仅残留 | 空 | 显示归档腿 | **新 ATM**(给下一组),不钉残留合约 | - ---- - -## 7. 交易所与账户 - -### 7.1 候选 - -| 交易所 | 永续 | 期权 | 备注 | -|--------|------|------|------| -| **币安**(实盘优先候选) | ETHUSDT | 欧洲期权 ETH-YYMMDD-K-C/P | USDT 统一保证金体验较好 | -| OKX | ETH-USDT-SWAP | ETH-USD_UM 等 | 与当前 SIM 默认行情接近 | - -有持仓时 **禁止切换交易所**。 - -### 7.2 账户要求 - -- 期权建议 **逐仓 / 独立保证金**,以便残留不挡新开。 -- API Key:交易权限最小化;IP 白名单;**密钥不出客户机房**(见商业化方案)。 -- 保留足够 USDT:保证金 + 权利金 + 多日磨损缓冲(见 §9)。 - ---- - -## 8. 侧重点与边界 - -| 要点 | 说明 | -|------|------| -| 吃波动 | 需要标的走动;横盘是主要磨损源 | -| 费用后净利 | 达标看扣费后,避免账面赚、平完亏 | -| 1:2 纪律 | 任意 k 下永续:期权 = 1:2 | -| 单活跃组 | 残留可共存,但不叠第二组活跃对冲 | -| 权利金是预算 | 拖到期亏权利金属设计内成本 | - -**不做**:卖方期权;横盘「智能识别」;多组并行活跃仓;承诺收益。 - ---- - -## 9. 资金与风险(按标准仓再 ×k) - -以下按 **k=1**、ETH≈1800–2200、杠杆 3× 粗算;其他倍数 **×k**。 - -### 9.1 单组占用(k=1) - -| 项目 | 约略 USDT | -|------|-----------| -| 永续保证金 | ≈ 600–750 | -| 期权权利金(2 ETH ATM 短期) | ≈ 15–60(波动大时更高) | -| 费用 / 滑点缓冲 | ≈ 20–50 | -| 逆向波动缓冲 | ≈ 300–800 | - -### 9.2 建议权益 - -| 档位 | 权益(k=1) | 说明 | -|------|-------------|------| -| 试跑 | 按 k=0.1 再估 | 先通流程 | -| 最小可用 | ≥ **3,000** | 仍偏紧 | -| 推荐起步 | ≥ **5,000** | 更符合概率样本 | -| 较舒适 | ≥ **10,000** | 连续磨损日更从容 | - -缩放:权益需求大致 **×k**(再另留固定运维缓冲)。 - -### 9.3 主要风险(实盘加重项) - -| 风险 | 说明 | 缓解 | -|------|------|------| -| 横盘磨损 | Theta 吃权利金 | 周末少开;到期认亏 | -| 滑点 / 拒单 / 部分成交 | 实盘比 SIM 严重 | 深度闸门;半边仓立即处理 | -| 强平 | 永续杠杆与缓冲不足 | 3×、留保证金、勿盲目加大 k | -| 期权流动性 | 远虚买一枯死 | 走目标 B;到期结算 | -| 限流 / 断连 | 延误开平 | 独立 IP、监控、可人工紧急平 | -| 规则外推 | SIM≠实盘 | 小 k 验证后再放大 | - ---- - -## 10. 实盘参数速查(标准仓) - -| 参数 | 标准建议 | 随 k 变化 | -|------|----------|-----------| -| `perp_qty_eth` | 1 | ×k | -| `option_qty_eth` | 2 | ×k | -| `leverage` | 3 | 一般不随 k 变 | -| `exit_mode` | fixed_usdt | — | -| `net_profit_target` | 15 | ×k | -| `premium_exit_multiple` | 1.0 | 不变 | -| `rest_seconds` | 300 | 可选不变 | -| `skip_weekends` | true | — | -| `min_option_hours` | 12 | — | -| `min_option_leverage` | 100 | — | -| `close_bid_mark_max_pct` | 30 | — | - -试跑建议:**k=0.1**(0.1 + 0.2 ETH),目标约 **1.5U**,跑通开平与到期后再加大。 - ---- - -## 11. 上线检查清单 - -1. SIM 同规则已跑通(含目标 A/B、到期、残留)。 -2. 设置页切 **LIVE**,二次确认输入 `LIVE`;OKX Key/Secret/Passphrase 写入 `.env`。 -3. 实盘授权档位 + 二次确认(见商业化方案)。 -4. 选定交易所、合约族、API 与 IP 白名单。 -5. 确定 k;写入永续/期权名义与净利目标(15×k)。 -6. 保证金与权利金缓冲到位;期权逐仓。 -7. 监控:活跃仓、残留列表、持仓盘口钉死、紧急全平可用。 -8. 先 k=0.1 试跑至少覆盖:开仓、目标平、到期或残留结算各一类。 - -> 软件侧:`MODE=LIVE` + OKX 密钥齐全后,策略开平仓走 OKX 私有下单;币安真下单尚未接入。 - ---- - -## 12. 修订记录 - -| 日期 | 说明 | -|------|------| -| 2026-07-26 | 初稿:由 SIM 策略说明改写实盘;标准仓 1+2 ETH;倍数 k 缩放与目标同比 | -| 2026-07-26 | 对齐软件:设置页 SIM/LIVE + API→.env;OKX 真下单 | +详细开平仓步骤、账户模式、检查清单见各所专篇。 diff --git a/docs/币安实盘策略说明.md b/docs/币安实盘策略说明.md new file mode 100644 index 0000000..d3abfb0 --- /dev/null +++ b/docs/币安实盘策略说明.md @@ -0,0 +1,127 @@ +# 币安实盘策略说明 + +> 专用于 **币安** LIVE。通用对冲逻辑见 [策略说明](./策略说明.md);OKX 见 [OKX实盘策略说明](./OKX实盘策略说明.md)。 +> **标准仓**:永续 **1 ETH** + 期权 **2 ETH** 名义(倍数 `k` 可缩放)。 +> 更新:2026-07-26 + +--- + +## 1. 一句话 + +ATM **期权买方** + **反向永续**;净利达标兑现,未达标拖到期。币安侧永续与欧洲期权均可 **USDT** 计价,**无需像 OKX 那样先把 USDT 换成 USDC**,账户准备更简单。 + +--- + +## 2. 合约与资金 + +| 腿 | 合约(默认) | 计价 / 保证金 | 说明 | +|----|--------------|---------------|------| +| 永续 | `ETHUSDT`(USDT-M) | **USDT** | 下单数量直接为 **ETH**(软件按名义 ETH 填 `quantity`) | +| 期权 | `ETH-YYMMDD-行权价-C/P`(欧洲期权 eapi) | **USDT** | 张数:名义 ETH ÷ 合约单位(默认约 **1**) | + +### 2.1 资金(相对 OKX 更简单) + +- 主要准备 **USDT** 即可:永续保证金 + 期权权利金 + 缓冲。 +- **不需要**日常 USDT→USDC 换汇(这是 OKX `ETH-USD_UM` 的常见摩擦)。 +- 仍须留足:保证金挤压、多日磨损、手续费。 + +### 2.2 账户模式建议 + +| 项 | 建议 | +|----|------| +| 永续 | USDT-M;**双向持仓(Hedge)** 时软件带 `positionSide=LONG/SHORT`;单向模式则用 `reduceOnly` | +| 期权 | 欧洲期权账户开通;买方;尽量独立保证金,便于残留不挡新开 | +| API | Key + Secret(**无 Passphrase**);分别开通 **期货 + 期权** 交易权限;IP 白名单 | + +### 2.3 与 OKX 对照 + +| 项 | 币安 | OKX | +|----|------|-----| +| 保证金币 | 多为统一 **USDT** | 永续 USDT + 期权常 **USDC** | +| 换汇 | 一般不需要 | 常需 USDT↔USDC | +| API | Key + Secret | Key + Secret + Passphrase | +| 永续数量 | ETH 名义 | 张(÷ ctVal) | +| 期权接口 | `eapi.binance.com` | OKX V5 `OPTION` | +| ATM 档 | 行权价网格常更粗 | 相对更细(视产品) | + +--- + +## 3. 标准仓与缩放 + +`永续 = 1×k ETH`,`期权 = 2×k ETH`,净利目标 ≈ `15×k` USDT。 +试跑建议 **k=0.1**。 + +注意:币安期权单位常为 1 ETH/张,同样 2 ETH 名义 ≈ **2 张**;OKX 默认 0.01 乘数时张数会大很多——**不要照搬张数,只锁 ETH 名义比 1:2**。 + +--- + +## 4. 开仓机制(币安) + +``` +选 ATM → 先 eapi 市价买期权 → 再 fapi 市价开永续 +永续失败 → eapi 市价卖期权回滚 +``` + +| 步骤 | 币安行为 | +|------|----------| +| 1 期权 | `POST /eapi/v1/order`,`type=MARKET`,`side=BUY`,`quantity`=张 | +| 2 永续 | `POST /fapi/v1/order`,`type=MARKET`;多=`BUY`+`LONG`,空=`SELL`+`SHORT` | +| 失败回滚 | 期权 `SELL` + `reduceOnly` | + +**与 OKX 差异**: + +- 双 API 域:期权 eapi、永续 fapi,限流与权限分开。 +- 永续数量用 **ETH**,期权用 **张**(单位≈1)。 +- 无 Passphrase;需确认账户是对冲模式还是单向(软件会探测 `positionSide/dual`)。 +- ATM 行权价档更粗时,更常触发「行权价相对现价偏上/偏下」选向规则。 + +选向表与通用策略说明相同。 + +--- + +## 5. 平仓机制(币安) + +| 路径 | 币安动作 | +|------|----------| +| **A 双腿全平** | eapi 市价卖期权 → fapi 市价平永续(`reduceOnly` / 反向 `positionSide`) | +| **B 远虚** | 只平永续;期权留到到期按 **内在价值** 结算;不挡下一组 | +| **到期** | 期权交易所结算;软件按内在价值记账;永续若在则平掉 | +| **紧急** | 尽量双腿市价 | + +期权远虚时 eapi 买一可能枯竭——优先走 B,勿死磕 A。 + +--- + +## 6. 监控与模式 + +有活跃仓钉持仓行权价;空仓/仅残留跟新 ATM。 + +设置页:交易所选 **币安**,运行模式 **LIVE**,填 Key/Secret → `.env`。切 LIVE 输入 `LIVE` 确认。 + +--- + +## 7. 风险(币安特有加重) + +| 风险 | 说明 | +|------|------| +| **期权权限 / 地区** | 未开通欧洲期权则无法下单 | +| **eapi 限流** | 比 fapi 更敏感;独立 IP、退避 | +| **ATM 粗档** | 方向偏差更大,更依赖选向规则 | +| **对冲模式不一致** | 单向账户却强制 `positionSide` 会拒单(软件已探测) | + +--- + +## 8. 上线检查(币安) + +1. SIM 在币安行情下跑通。 +2. 期货 + 期权交易权限、USDT 余额到位。 +3. API Key/Secret + LIVE 确认。 +4. k=0.1 试开平 / 到期或残留。 + +--- + +## 9. 修订记录 + +| 日期 | 说明 | +|------|------| +| 2026-07-26 | 初稿:币安 LIVE 接入;强调 USDT 统一、eapi/fapi 开平差异 vs OKX | diff --git a/docs/策略说明.md b/docs/策略说明.md index dcba093..bf2ffe2 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -1,10 +1,10 @@ # ETH 永续 + 期权对冲策略说明 > 依据当前代码逻辑整理(SIM 默认真值参数)。 -> 关联:[开发方案](./开发方案.md)、[商业化与授权方案](./商业化与授权方案.md)、[实盘策略说明](./实盘策略说明.md) +> 关联:[开发方案](./开发方案.md)、[商业化与授权方案](./商业化与授权方案.md)、[实盘索引](./实盘策略说明.md)、[OKX实盘](./OKX实盘策略说明.md)、[币安实盘](./币安实盘策略说明.md) > 更新:2026-07-26 -**运行模式**:设置页「运行模式」可切 **SIM / LIVE**;交易所 API 录入后写入服务器 `.env`(不回传明文)。LIVE 须二次确认输入 `LIVE`;当前 **OKX** 可真下单,币安仅存密钥。有持仓时不可切模式。 +**运行模式**:设置页「运行模式」可切 **SIM / LIVE**;交易所 API 录入后写入服务器 `.env`(不回传明文)。LIVE 须二次确认输入 `LIVE`;**OKX / 币安均可真下单**(须选对应当前交易所并配齐密钥)。有持仓时不可切模式。 --- @@ -302,7 +302,7 @@ 系统默认虚拟权益 `initial_equity = 10,000` USDT(策略设置可改;保存且数值变更时在无持仓下重置账本),**不代表**实盘建议入金。 -日后币安实盘试跑建议仓位:**永续 0.1 ETH / 期权 0.2 ETH 名义**,并同比下调净利目标。完整实盘口径见 [实盘策略说明](./实盘策略说明.md)。 +实盘试跑建议仓位:**永续 0.1 ETH / 期权 0.2 ETH 名义**(k=0.1),并同比下调净利目标。分所口径见 [OKX实盘策略说明](./OKX实盘策略说明.md)、[币安实盘策略说明](./币安实盘策略说明.md)。 --- diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index fb70295..663d6c4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -203,6 +203,7 @@ export type StrategySettings = { net_profit_target: number; premium_exit_multiple: number; rest_seconds: number; + live_order_interval_sec?: number; skip_weekends: boolean; initial_equity: number; leverage: number; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 0e41a3e..7568bd1 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -28,6 +28,7 @@ export default function SettingsPage() { const [netTarget, setNetTarget] = useState(15); const [premMult, setPremMult] = useState(1); const [rest, setRest] = useState(300); + const [orderInterval, setOrderInterval] = useState(1); const [skipWeekends, setSkipWeekends] = useState(true); const [leverage, setLeverage] = useState(3); const [minHours, setMinHours] = useState(12); @@ -67,6 +68,7 @@ export default function SettingsPage() { setNetTarget(s.net_profit_target ?? 15); setPremMult(s.premium_exit_multiple ?? 1); setRest(s.rest_seconds); + setOrderInterval(s.live_order_interval_sec ?? 1); setSkipWeekends(s.skip_weekends !== false); setLeverage(s.leverage ?? 3); setMinHours(s.min_option_hours ?? 12); @@ -127,6 +129,7 @@ export default function SettingsPage() { net_profit_target: netTarget, premium_exit_multiple: premMult, rest_seconds: rest, + live_order_interval_sec: orderInterval, skip_weekends: skipWeekends, leverage, min_option_hours: minHours, @@ -446,6 +449,23 @@ export default function SettingsPage() { onChange={(e) => setRest(Number(e.target.value))} /> +
+ + setOrderInterval(Number(e.target.value))} + /> +

+ LIVE 私有下单/查单间隔,默认 1s(非高频建议 ≥1)。范围 + 0.2–30。保存后立即生效。 +

+