From 62c91d4bd0e6960b1021005f038afc093bec2639 Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 2 Aug 2026 14:19:21 +0800 Subject: [PATCH] Close residual options when premium recovers above configurable threshold. Co-authored-by: Cursor --- backend/app/api/settings.py | 18 ++ backend/app/config.py | 3 + backend/app/live/binance_executor.py | 63 ++++++ backend/app/live/executor.py | 64 ++++++ backend/app/notify/wecom.py | 1 + backend/app/sim/matcher.py | 226 +++++++++++++++++++ backend/app/strategy/engine.py | 37 +++ backend/tests/test_residual_premium_close.py | 150 ++++++++++++ docs/策略说明.md | 15 +- frontend/src/api/client.ts | 2 + frontend/src/labels.ts | 1 + frontend/src/pages/Settings.tsx | 34 ++- 12 files changed, 603 insertions(+), 11 deletions(-) create mode 100644 backend/tests/test_residual_premium_close.py diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 2e979c7..5bb56b4 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -46,6 +46,8 @@ KEYS = ( "fixed_direction_enabled", "fixed_perp_side", "close_bid_mark_max_pct", + "residual_min_premium_pct", + "residual_close_check_sec", "perp_qty_eth", "option_qty_eth", "show_manual_trade_buttons", @@ -82,6 +84,8 @@ class StrategySettingsBody(BaseModel): fixed_direction_enabled: bool | None = None fixed_perp_side: str | None = Field(default=None, pattern="^(long|short)$") close_bid_mark_max_pct: float | None = Field(default=None, ge=1, le=100) + residual_min_premium_pct: float | None = Field(default=None, ge=1, le=100) + residual_close_check_sec: int | None = Field(default=None, ge=30, le=86400) 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) show_manual_trade_buttons: bool | None = None @@ -213,6 +217,20 @@ def _read_settings() -> dict: db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct)) or s.close_bid_mark_max_pct ), + "residual_min_premium_pct": float( + db.get_setting( + "residual_min_premium_pct", str(s.residual_min_premium_pct) + ) + or s.residual_min_premium_pct + ), + "residual_close_check_sec": int( + float( + db.get_setting( + "residual_close_check_sec", str(s.residual_close_check_sec) + ) + or s.residual_close_check_sec + ) + ), "perp_qty_eth": float( db.get_setting("perp_qty_eth", str(s.perp_qty_eth)) or s.perp_qty_eth ), diff --git a/backend/app/config.py b/backend/app/config.py index 5b683bf..bc58a24 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -82,6 +82,9 @@ class Settings(BaseSettings): fixed_direction_enabled: bool = False fixed_perp_side: str = "long" # long|short;long→买Put,short→买Call close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差% + # 残留期权中途平:当前买一权利金 ≥ 初始权利金 × 该% 才尝试卖出 + residual_min_premium_pct: float = 20.0 + residual_close_check_sec: int = 300 # 残留巡检间隔(秒) perp_qty_eth: float = 1.0 option_qty_eth: float = 2.0 db_path: str = "" # empty -> backend/data/hedge.db diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index 9b80dbd..0590d99 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -1149,6 +1149,69 @@ class BinanceLiveExecutor(Matcher): }, ) + def try_close_one_residual(self, row: dict) -> dict | None: + """LIVE-BN:权利金达标后交易所市价卖出归档期权。""" + err = self._guard_live() + if err: + logger.warning("residual premium close blocked: %s", err) + return None + skip, close_bid, _oq = self._evaluate_residual_premium_close(row) + if skip or close_bid is None: + if skip: + logger.debug( + "residual premium close skip %s: %s", + row.get("group_id"), + skip, + ) + return None + option_inst_id = str(row.get("option_inst_id") or "") + opt_contracts = float(row.get("option_qty_contracts") or 0) + opt_qty = float(row.get("option_qty_eth") or 0) + if opt_contracts <= 0: + ct = self._ct_mult(option_inst_id) + opt_contracts = float(contracts_for_eth(opt_qty, ct) or 0) + if opt_contracts <= 0: + logger.warning( + "residual premium close skip %s: bad contracts", row.get("group_id") + ) + return None + client = self._client() + try: + opt_live = client.place_option_market( + symbol=option_inst_id, + side="SELL", + quantity=max(1.0, opt_contracts), + reduce_only=True, + ) + except Exception as e: + logger.warning( + "residual premium close exchange sell failed %s: %s", + row.get("group_id"), + e, + ) + return None + of_px = float(opt_live.avg_px) + of_fee = float(opt_live.fee) + filled_c = ( + float(opt_live.sz) + if opt_live.sz and float(opt_live.sz) > 0 + else opt_contracts + ) + opt_qty = eth_from_contracts(filled_c, self._ct_mult(option_inst_id)) + row = {**row, "option_qty_eth": opt_qty, "option_qty_contracts": filled_c} + of_notional = of_px * opt_qty + now_ms = int(time.time() * 1000) + return self._book_residual_market_close( + row, + fill_px=of_px, + fee=of_fee, + notional=of_notional, + slip=0.0, + now_ms=now_ms, + note="LIVE-BN residual mid-close by premium recovery", + exec_mode="LIVE", + ) + def close_perp_abandon_option( self, *, reason: str = "target_perp_only", require_deep_otm: bool = True ) -> CloseResult: diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index a615059..ab2ab6f 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -1192,6 +1192,70 @@ class OkxLiveExecutor(Matcher): }, ) + def try_close_one_residual(self, row: dict) -> dict | None: + """LIVE:权利金达标后交易所市价卖出归档期权。""" + err = self._guard_live() + if err: + logger.warning("residual premium close blocked: %s", err) + return None + skip, close_bid, _oq = self._evaluate_residual_premium_close(row) + if skip or close_bid is None: + if skip: + logger.debug( + "residual premium close skip %s: %s", + row.get("group_id"), + skip, + ) + return None + option_inst_id = str(row.get("option_inst_id") or "") + opt_contracts = float(row.get("option_qty_contracts") or 0) + opt_qty = float(row.get("option_qty_eth") or 0) + if opt_contracts <= 0: + ct = self._ct_mult(option_inst_id) + opt_contracts = float(contracts_for_eth(opt_qty, ct) or 0) + if opt_contracts <= 0: + logger.warning( + "residual premium close skip %s: bad contracts", row.get("group_id") + ) + return None + client = self._client() + try: + opt_live = client.place_market( + inst_id=option_inst_id, + side="sell", + sz=str(max(1, int(round(opt_contracts)))), + td_mode="cash", + reduce_only=True, + ) + except Exception as e: + logger.warning( + "residual premium close exchange sell failed %s: %s", + row.get("group_id"), + e, + ) + return None + of_px = float(opt_live.avg_px) + of_fee = float(opt_live.fee) + filled_c = ( + float(opt_live.sz) + if opt_live.sz and float(opt_live.sz) > 0 + else opt_contracts + ) + opt_qty = eth_from_contracts(filled_c, self._ct_mult(option_inst_id)) + row = {**row, "option_qty_eth": opt_qty, "option_qty_contracts": filled_c} + of_notional = of_px * opt_qty + now_ms = int(time.time() * 1000) + return self._book_residual_market_close( + row, + fill_px=of_px, + fee=of_fee, + notional=of_notional, + slip=0.0, + now_ms=now_ms, + note="LIVE residual mid-close by premium recovery", + exec_mode="LIVE", + ) + def close_perp_abandon_option( self, *, reason: str = "target_perp_only", require_deep_otm: bool = True ) -> CloseResult: diff --git a/backend/app/notify/wecom.py b/backend/app/notify/wecom.py index 30f3ee5..62f6bb7 100644 --- a/backend/app/notify/wecom.py +++ b/backend/app/notify/wecom.py @@ -37,6 +37,7 @@ CLOSE_REASON_ZH: dict[str, str] = { "manual": "手动全平", "perp_pending_retry": "续平永续", "liquidity_retry": "等待流动性后全平", + "residual_premium_close": "残留期权·权利金回收中途平", "unknown": "未知原因", } diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 614b323..adf3eb4 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -829,6 +829,232 @@ class Matcher: ) return [dict(r) for r in rows] + def _residual_min_premium_pct(self) -> float: + s = get_settings() + return float( + self.ledger.get_setting_float( + "residual_min_premium_pct", s.residual_min_premium_pct + ) + ) + + def _evaluate_residual_premium_close( + self, row: dict[str, Any] + ) -> tuple[str | None, float | None, Any]: + """ + 残留中途平前置:权利金比例 + 买一流动性。 + 返回 (skip_reason, close_bid, option_quote);skip_reason 非空则本轮不卖。 + """ + s = get_settings() + initial_premium = float(row.get("initial_premium") or 0) + opt_qty = float(row.get("option_qty_eth") or 0) + if initial_premium <= 0 or opt_qty <= 0: + return ("invalid_initial_premium_or_qty", None, None) + + option_inst_id = str(row.get("option_inst_id") or "") + if not option_inst_id: + return ("missing_option_inst", None, None) + + oq = self._quote_held_option(option_inst_id) + if oq is None or oq.bid is None: + return ("option_bid_unavailable", None, None) + + close_bid = float(oq.bid) + current_premium = close_bid * opt_qty + min_pct = self._residual_min_premium_pct() + threshold = initial_premium * (min_pct / 100.0) + if current_premium + 1e-12 < threshold: + return ( + f"premium_below_threshold curr={current_premium:.4f} " + f"need>={threshold:.4f} ({min_pct:g}%)", + None, + None, + ) + + ct_mult = self._ct_mult(option_inst_id) + if not bid_covers_eth( + bid_sz_contracts=oq.bid_sz, + ct_mult=ct_mult, + need_eth=opt_qty, + ): + return ("option_bid_liquidity_insufficient", None, None) + + max_dev = self.ledger.get_setting_float( + "close_bid_mark_max_pct", s.close_bid_mark_max_pct + ) + ok_dev, why = bid_mark_ok(bid=close_bid, mark=oq.mark_px, max_dev_pct=max_dev) + if not ok_dev: + return (why or "bid_mark_deviation", None, None) + + strike = row.get("strike") + spot = self._close_spot_px(get_session().snapshot()) + intrinsic: float | None = None + if strike is not None and spot is not None: + intrinsic = option_intrinsic( + option_side=str(row["option_side"]), + strike=float(strike), + spot=float(spot), + ) + resolved = resolve_option_close_bid( + bid=close_bid, + mark=oq.mark_px, + intrinsic=intrinsic, + bypass_liquidity=False, + ) + if resolved is None: + return ("option_close_px_unavailable", None, None) + return (None, float(resolved), oq) + + def _book_residual_market_close( + self, + row: dict[str, Any], + *, + fill_px: float, + fee: float, + notional: float, + slip: float, + now_ms: int, + note: str, + exec_mode: str | None = None, + ) -> dict[str, Any]: + """买一卖出残留后的入账与结清(SIM/LIVE 共用)。""" + group_id = str(row["group_id"]) + opt_qty = float(row["option_qty_eth"]) + opt_entry = float(row["option_entry_px"]) + opt_pnl = (float(fill_px) - opt_entry) * opt_qty + opt_cash = float(notional) - float(fee) + self.ledger.apply_cash( + opt_cash, + kind="close_option", + group_id=group_id, + note=note, + allow_negative=not get_settings().is_sim, + ) + + fill_cols = ( + "group_id, leg, action, side, inst_id, qty_eth, qty_contracts, " + "base_px, fill_px, fee, slip, notional, ts_ms" + ) + fill_vals: list[Any] = [ + group_id, + "option", + "close", + "flat", + str(row["option_inst_id"]), + opt_qty, + float(row["option_qty_contracts"] or 0), + float(fill_px), + float(fill_px), + float(fee), + float(slip), + float(notional), + now_ms, + ] + if exec_mode: + fill_cols += ", exec_mode" + fill_vals.append(exec_mode) + + with self.db._lock: + self.db._conn.execute( + f"""INSERT INTO fills({fill_cols}) + VALUES ({",".join("?" for _ in fill_vals)})""", + tuple(fill_vals), + ) + fills = self.db._conn.execute( + "SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,) + ).fetchall() + from ..sim.pnl import summarize_fills_pnl + + summary = summarize_fills_pnl(list(fills)) + net = summary.get("net_pnl") + if net is None: + net = opt_pnl - float(fee) + g = self.db._conn.execute( + "SELECT fees, slip_cost FROM groups WHERE group_id=?", (group_id,) + ).fetchone() + fees = float(g["fees"] or 0) + float(fee) if g else float(fee) + slip_total = float(g["slip_cost"] or 0) + float(slip) if g else float(slip) + self.db._conn.execute( + """UPDATE residual_options SET status=?, settled_at_ms=?, settle_px=?, settle_pnl=?, note=? + WHERE group_id=?""", + ( + "settled", + now_ms, + float(fill_px), + opt_pnl, + note, + group_id, + ), + ) + self.db._conn.execute( + """UPDATE groups SET status=?, close_at_ms=COALESCE(close_at_ms, ?), + close_reason=COALESCE(close_reason, ?), realized_pnl=?, fees=?, slip_cost=? + WHERE group_id=?""", + ( + "closed", + now_ms, + "residual_premium_close", + float(net), + fees, + slip_total, + group_id, + ), + ) + self.db._conn.commit() + + return { + "group_id": group_id, + "option_pnl": opt_pnl, + "settle_px": float(fill_px), + "net_pnl": float(net), + "reason": "residual_premium_close", + "current_premium": float(fill_px) * opt_qty, + "initial_premium": float(row.get("initial_premium") or 0), + } + + def try_close_one_residual(self, row: dict[str, Any]) -> dict[str, Any] | None: + """SIM:权利金达标且流动性通过则本地吃买一平残留。""" + skip, close_bid, oq = self._evaluate_residual_premium_close(row) + if skip or close_bid is None or oq is None: + if skip: + logger.debug( + "residual premium close skip %s: %s", + row.get("group_id"), + skip, + ) + return None + of = option_fill( + action="close", + bid=float(close_bid), + ask=float(oq.ask or close_bid), + qty_eth=float(row["option_qty_eth"]), + fee_rate=self._fee_rate(), + ) + now_ms = int(time.time() * 1000) + return self._book_residual_market_close( + row, + fill_px=of.fill_px, + fee=of.fee, + notional=of.notional, + slip=of.slip, + now_ms=now_ms, + note="residual mid-close by premium recovery", + ) + + def try_close_pending_residuals(self) -> list[dict[str, Any]]: + """巡检全部 pending 残留,尝试权利金回收平仓。""" + out: list[dict[str, Any]] = [] + for row in self.list_residual_options(pending_only=True): + try: + r = self.try_close_one_residual(row) + except Exception: + logger.exception( + "try_close_one_residual failed group=%s", row.get("group_id") + ) + continue + if r: + out.append(r) + return out + def settle_due_residuals(self, *, now_ms: int | None = None) -> list[dict[str, Any]]: """到期结算所有 pending 残留期权(不扫描进活跃组平仓)。""" now = int(now_ms if now_ms is not None else time.time() * 1000) diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 305217c..903a2f3 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -31,6 +31,7 @@ class StrategyEngine: self._lock = asyncio.Lock() self._retry_gate = LiveRetryGate() self._extra_sleep_sec = 0.0 + self._last_residual_premium_check_ms = 0 def refresh_executor(self) -> None: """MODE 变更后刷新执行器。""" @@ -545,6 +546,41 @@ class StrategyEngine: async def _settle_residuals(self) -> None: await asyncio.to_thread(self.matcher.settle_due_residuals) + async def _maybe_close_residuals_by_premium(self) -> None: + """残留期权:权利金回升达标时周期性尝试中途平仓。""" + s = get_settings() + interval_sec = int( + self.ledger.get_setting_int( + "residual_close_check_sec", s.residual_close_check_sec + ) + ) + interval_sec = max(30, interval_sec) + now_ms = int(time.time() * 1000) + if now_ms - self._last_residual_premium_check_ms < interval_sec * 1000: + return + self._last_residual_premium_check_ms = now_ms + fn = getattr(self.matcher, "try_close_pending_residuals", None) + if not callable(fn): + return + try: + closed = await asyncio.to_thread(fn) + except Exception: + logger.exception("try_close_pending_residuals failed") + return + if not closed: + return + for item in closed: + try: + from ..notify import wecom + + wecom.notify_close( + reason="residual_premium_close", + detail="残留期权权利金回收中途平", + data=item if isinstance(item, dict) else {}, + ) + except Exception: + logger.exception("wecom notify residual premium close failed") + async def _maybe_expiry_close(self) -> bool: """若持仓已到期则强制全平。返回是否触发到期平仓。""" await self._settle_residuals() @@ -613,6 +649,7 @@ class StrategyEngine: async def _tick_manage_positions(self) -> None: """有仓时的盯盘:残留结算 / 半仓修复 / 目标平 / 到期平。不新开仓。""" await self._settle_residuals() + await self._maybe_close_residuals_by_premium() s = get_settings() st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1") diff --git a/backend/tests/test_residual_premium_close.py b/backend/tests/test_residual_premium_close.py new file mode 100644 index 0000000..c802303 --- /dev/null +++ b/backend/tests/test_residual_premium_close.py @@ -0,0 +1,150 @@ +"""残留期权:权利金回升达标后中途平。""" + +from __future__ import annotations + +from types import SimpleNamespace + +from app.models.db import Database +from app.sim.matcher import Matcher + + +def _seed_residual( + db: Database, + *, + group_id: str = "G-res", + initial_premium: float = 100.0, + qty: float = 2.0, + entry_px: float = 50.0, +) -> None: + now = 1_700_000_000_000 + with db._lock: + db._conn.execute( + """INSERT INTO groups( + group_id, status, bias, option_side, perp_side, option_inst_id, + strike, expiry_ymd, initial_premium, open_at_ms, close_at_ms, + close_reason, realized_pnl, fees, slip_cost + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "option_residual", + "test", + "call", + "short", + "ETH-USD_UM-260801-2000-C", + 2000.0, + "260801", + initial_premium, + now - 10_000, + now - 5_000, + "target_perp_only", + 10.0, + 1.0, + 0.0, + ), + ) + 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, + "ETH-USD_UM-260801-2000-C", + "call", + qty, + 200.0, + entry_px, + 2000.0, + "260801", + now + 86_400_000, + 1900.0, + initial_premium, + "pending", + now - 5_000, + "test residual", + ), + ) + db._conn.commit() + + +def test_residual_premium_below_threshold_skips(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("MODE", "SIM") + db = Database(tmp_path / "below.db") + db.set_setting("residual_min_premium_pct", "20") + _seed_residual(db, initial_premium=100.0, qty=2.0) + m = Matcher(db) + + # bid=5 → premium=10 < 20 + oq = SimpleNamespace(bid=5.0, ask=5.5, bid_sz=10_000.0, mark_px=5.0) + monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq) + monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0) + + assert m.try_close_one_residual(m.list_residual_options()[0]) is None + row = db.fetchone( + "SELECT status FROM residual_options WHERE group_id=?", ("G-res",) + ) + assert row is not None and row["status"] == "pending" + db.close() + + +def test_residual_premium_above_threshold_closes(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("MODE", "SIM") + db = Database(tmp_path / "above.db") + db.set_setting("residual_min_premium_pct", "20") + _seed_residual(db, initial_premium=100.0, qty=2.0, entry_px=50.0) + m = Matcher(db) + + # bid=15 → premium=30 >= 20 + oq = SimpleNamespace(bid=15.0, ask=15.5, bid_sz=10_000.0, mark_px=15.0) + monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq) + monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0) + monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01) + + out = m.try_close_one_residual(m.list_residual_options()[0]) + assert out is not None + assert out["reason"] == "residual_premium_close" + row = db.fetchone( + "SELECT status, settle_px FROM residual_options WHERE group_id=?", ("G-res",) + ) + assert row is not None and row["status"] == "settled" + g = db.fetchone("SELECT status FROM groups WHERE group_id=?", ("G-res",)) + assert g is not None and g["status"] == "closed" + db.close() + + +def test_residual_liquidity_fail_skips(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("MODE", "SIM") + db = Database(tmp_path / "liq.db") + db.set_setting("residual_min_premium_pct", "20") + _seed_residual(db, initial_premium=100.0, qty=2.0) + m = Matcher(db) + + # premium ok but depth tiny + oq = SimpleNamespace(bid=15.0, ask=15.5, bid_sz=1.0, mark_px=15.0) + monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq) + monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0) + monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01) + + assert m.try_close_one_residual(m.list_residual_options()[0]) is None + row = db.fetchone( + "SELECT status FROM residual_options WHERE group_id=?", ("G-res",) + ) + assert row is not None and row["status"] == "pending" + db.close() + + +def test_settings_exposes_residual_min_premium_pct(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("MODE", "SIM") + from app.api import settings as settings_api + from app.models.db import set_db + + d = Database(tmp_path / "set.db") + set_db(d) + try: + d.set_setting("residual_min_premium_pct", "35") + payload = settings_api._read_settings() + assert float(payload["residual_min_premium_pct"]) == 35.0 + finally: + set_db(None) + d.close() diff --git a/docs/策略说明.md b/docs/策略说明.md index ef4cee3..ddcc4ab 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -180,11 +180,10 @@ k = floor(budget / (2A + I×fee_rate×3) × 10) / 10 - 条件:净利达标,且期权已是 **虚值且远虚**(内在价值 ≈ 0)。 - 动作: 1. **只市价平掉永续**,兑现净利里永续那一截; - 2. 本张期权 **不再盯盘、不再参与平仓扫描**,归档为「到期残留」; - 3. 因期权 **逐仓**,残留 **不占用活跃持仓**,**不挡住下一组开仓**; - 4. 下一组开平仓 **只扫当前活跃组期权**,不扫描历史残留腿; - 5. 残留期权到到期日再按 **内在价值** 单独结算(多半接近 0); - 6. 页面:**活跃持仓区变空**;归档腿出现在「残留期权(待到期)」;下方期权盘口 **切回新 ATM**(见 4.6)。 + 2. 本张期权归档为「残留」:不占用活跃持仓、**不挡住下一组开仓**;下一组只扫当前活跃组期权; + 3. **中途回收(可配置)**:默认每 **5 分钟**巡检 pending 残留;当 **买一权利金 ≥ 初始权利金 × 比例**(默认 **20%**,系统设置「残留期权回收」可改)且通过买一流动性闸门时,**市价卖掉**该残留并结清; + 4. 未达比例或闸门不过 → 继续等到下次巡检,或到期按 **内在价值** 结算(多半接近 0); + 5. 页面:**活跃持仓区变空**;归档腿出现在「残留期权(待到期)」;下方期权盘口 **切回新 ATM**(见 4.6)。 残留到期结算口径: @@ -213,7 +212,7 @@ k = floor(budget / (2A + I×fee_rate×3) × 10) / 10 - 买一相对标记偏差默认 ≤ **30%**(`close_bid_mark_max_pct`); - 不满足 → `liquidity_wait`,继续等待。 -**4.1.B / 到期 / 紧急全平**:不适用「必须卖掉期权买一」这套闸门(到期与残留按内在价值;紧急可绕过)。 +**4.1.B 归档当下 / 到期 / 紧急全平**:归档与到期不强制吃买一(到期/强制按内在价值;紧急可绕过)。**残留中途回收**仍走买一深度 + 偏差闸门(同 4.1.A)。 **紧急全平**:活跃组尽量双腿平掉;残留期权一并按内在价值结算;成功后进入组间休息(与手动全平相同)。 @@ -223,6 +222,7 @@ k = floor(budget / (2A + I×fee_rate×3) × 10) / 10 |--------|------| | `fixed_usdt` / `premium_multiple` | 目标平仓 · 双腿全平(4.1.A) | | `target_perp_only` | 目标平仓 · 只平永续,期权归档到期(4.1.B) | +| `residual_premium_close` | 残留期权 · 权利金回升达标后中途平 | | `expiry` | 到期结算(活跃组或残留期权) | | `emergency` | 界面紧急全平 | | `manual` | 手动平仓 | @@ -237,7 +237,8 @@ k = floor(budget / (2A + I×fee_rate×3) × 10) / 10 └─ 否 → 持有直到到期 → 内在价值结算(+ 若有永续则平永续) 残留期权(已归档) - └─ 仅到期结算;不参与盯盘、不参与下一组平仓扫描 + ├─ 周期性:买一权利金 / 初始 ≥ 设置% 且流动性过 → 市价卖出结清 + └─ 否则到期内在价值结算;不挡下一组开仓 ``` ### 4.6 行情监控与页面展示 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e7e19ff..cad1f16 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -339,6 +339,8 @@ export type StrategySettings = { fixed_direction_enabled?: boolean; fixed_perp_side?: "long" | "short"; close_bid_mark_max_pct?: number; + residual_min_premium_pct?: number; + residual_close_check_sec?: number; perp_qty_eth?: number; option_qty_eth?: number; show_manual_trade_buttons?: boolean; diff --git a/frontend/src/labels.ts b/frontend/src/labels.ts index a8b9a53..7a1daea 100644 --- a/frontend/src/labels.ts +++ b/frontend/src/labels.ts @@ -27,6 +27,7 @@ const CLOSE_REASON_ZH: Record = { fixed_usdt: "固定净盈利达标·双腿全平", premium_multiple: "权利金倍数达标·双腿全平", target_perp_only: "净盈利达标·只平永续(期权归档)", + residual_premium_close: "残留期权·权利金回收中途平", expiry: "到期结算", emergency: "紧急全平", manual: "手动平仓", diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 5f14bd0..eb8af7e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -85,6 +85,7 @@ export default function SettingsPage() { const [fixedDirOn, setFixedDirOn] = useState(false); const [fixedPerpSide, setFixedPerpSide] = useState<"long" | "short">("long"); const [closeDevPct, setCloseDevPct] = useState(30); + const [residualMinPremPct, setResidualMinPremPct] = useState(20); const [perpQty, setPerpQty] = useState(1); const [optQty, setOptQty] = useState(2); const [showManualTrade, setShowManualTrade] = useState(false); @@ -196,6 +197,7 @@ export default function SettingsPage() { setFixedDirOn(s.fixed_direction_enabled === true); setFixedPerpSide(s.fixed_perp_side === "short" ? "short" : "long"); setCloseDevPct(s.close_bid_mark_max_pct ?? 30); + setResidualMinPremPct(s.residual_min_premium_pct ?? 20); setPerpQty(s.perp_qty_eth ?? 1); setOptQty(s.option_qty_eth ?? 2); setShowManualTrade(s.show_manual_trade_buttons === true); @@ -362,6 +364,7 @@ export default function SettingsPage() { fixed_direction_enabled: fixedDirOn, fixed_perp_side: fixedPerpSide, close_bid_mark_max_pct: closeDevPct, + residual_min_premium_pct: residualMinPremPct, show_manual_trade_buttons: showManualTrade, sizing_mode: sizingMode, risk_leverage_basis: riskLeverageBasis, @@ -1160,6 +1163,23 @@ export default function SettingsPage() { onChange={(e) => setCloseDevPct(Number(e.target.value))} /> +
+ + + setResidualMinPremPct(Number(e.target.value)) + } + /> +
) : null} @@ -1297,10 +1317,16 @@ export default function SettingsPage() { ) : null} {stratSub === "exit" ? ( -
  • - 平仓买一/标记最大偏差:主要用于 SIM - 流动性闸门;LIVE 以交易所能否成交为准。 -
  • + <> +
  • + 平仓买一/标记最大偏差:主要用于 SIM + 流动性闸门;LIVE 以交易所能否成交为准。 +
  • +
  • + 残留期权回收比例:只平永续后,当买一权利金回升到初始权利金的该比例及以上时,才尝试中途卖掉归档期权;默认 + 20%。未达标则等到期按内在价值结算。 +
  • + ) : null} {stratSub === "pace" ? ( <>