diff --git a/backend/app/api/trades.py b/backend/app/api/trades.py index a9b3679..e087df2 100644 --- a/backend/app/api/trades.py +++ b/backend/app/api/trades.py @@ -5,6 +5,7 @@ from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException from ..models.db import get_db +from ..sim.pnl import summarize_fills_pnl from .auth import require_user router = APIRouter(prefix="/api/trades", tags=["trades"]) @@ -14,68 +15,6 @@ def _row(r: Any) -> dict: return dict(r) -def summarize_fills_pnl(fills: list[Any]) -> dict[str, float | None]: - """ - 从成交明细重算腿盈亏与净盈亏。 - 价差盈亏按 fill_px(成交价);手续费另扣。 - 净盈亏 = 期权盈亏 + 永续盈亏 − 全部手续费(开+平)。 - """ - rows = [dict(x) for x in fills] - opt_open = next( - (f for f in rows if f.get("leg") == "option" and f.get("action") == "open"), - None, - ) - opt_close = next( - (f for f in rows if f.get("leg") == "option" and f.get("action") == "close"), - None, - ) - perp_open = next( - (f for f in rows if f.get("leg") == "perp" and f.get("action") == "open"), - None, - ) - perp_close = next( - (f for f in rows if f.get("leg") == "perp" and f.get("action") == "close"), - None, - ) - - option_pnl: float | None = None - if opt_open and opt_close: - qty = float(opt_open.get("qty_eth") or opt_close.get("qty_eth") or 0) - option_pnl = (float(opt_close["fill_px"]) - float(opt_open["fill_px"])) * qty - - perp_pnl: float | None = None - if perp_open and perp_close: - qty = float(perp_open.get("qty_eth") or perp_close.get("qty_eth") or 0) - side = str(perp_open.get("side") or "") - o = float(perp_open["fill_px"]) - c = float(perp_close["fill_px"]) - if side == "long": - perp_pnl = (c - o) * qty - else: - perp_pnl = (o - c) * qty - - fees_total = sum(float(f.get("fee") or 0) for f in rows) - gross = None - net = None - if option_pnl is not None and perp_pnl is not None: - gross = option_pnl + perp_pnl - net = gross - fees_total - elif option_pnl is not None: - gross = option_pnl - net = option_pnl - fees_total - elif perp_pnl is not None: - gross = perp_pnl - net = perp_pnl - fees_total - - return { - "option_pnl": option_pnl, - "perp_pnl": perp_pnl, - "fees_total": fees_total, - "gross_pnl": gross, - "net_pnl": net, - } - - @router.get("/groups") async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict: db = get_db() diff --git a/backend/app/models/db.py b/backend/app/models/db.py index af27207..0bc2df1 100644 --- a/backend/app/models/db.py +++ b/backend/app/models/db.py @@ -97,6 +97,28 @@ CREATE TABLE IF NOT EXISTS strategy_state ( last_error TEXT, updated_at_ms INTEGER NOT NULL ); + +CREATE TABLE IF NOT EXISTS residual_options ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id TEXT NOT NULL UNIQUE, + option_inst_id TEXT NOT NULL, + option_side TEXT NOT NULL, + option_qty_eth REAL NOT NULL, + option_qty_contracts REAL, + option_entry_px REAL NOT NULL, + strike REAL, + expiry_ymd TEXT, + expiry_ms INTEGER, + entry_index_px REAL, + initial_premium REAL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + created_at_ms INTEGER NOT NULL, + settled_at_ms INTEGER, + settle_px REAL, + settle_pnl REAL, + note TEXT, + FOREIGN KEY(group_id) REFERENCES groups(group_id) +); """ diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 1c660a6..96bd708 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -13,6 +13,7 @@ from ..strategy.session import get_session from .ledger import Ledger from .liquidity import bid_covers_eth, bid_mark_ok, contracts_for_eth from .pricing import ( + is_deep_otm, option_expiry_settle, option_fill, option_intrinsic, @@ -576,6 +577,317 @@ class Matcher: }, ) + def option_is_deep_otm(self) -> bool: + """活跃组期权是否远虚(内在价值≈0)。""" + pos = self.current_position() + if pos.get("status") != "open" or not pos.get("group_id"): + return False + group_id = str(pos["group_id"]) + option_inst_id = str(pos.get("option_inst_id") or "") + option_side = str(pos.get("option_side") or "") + strike = self._group_strike(group_id, option_inst_id) + spot = self._close_spot_px(get_session().snapshot()) + if strike is None or spot is None: + return False + return is_deep_otm( + option_side=option_side, strike=float(strike), spot=float(spot) + ) + + def close_perp_abandon_option(self, *, reason: str = "target_perp_only") -> CloseResult: + """ + 目标平仓 B:只平永续,期权归档为到期残留(不再盯盘、不挡新开)。 + """ + s = get_settings() + pos = self.current_position() + if pos.get("status") != "open" or not pos.get("group_id"): + return CloseResult(ok=False, detail="无持仓可平") + + group_id = str(pos["group_id"]) + sess = get_session() + snap = sess.snapshot() + if not snap.perp or snap.perp.bid is None or snap.perp.ask is None: + return CloseResult(ok=False, detail="永续盘口不可用") + + option_inst_id = str(pos["option_inst_id"]) + option_side = str(pos["option_side"]) + strike = self._group_strike(group_id, option_inst_id) + spot = self._close_spot_px(snap) + if strike is None or spot is None: + return CloseResult(ok=False, detail="无法判断远虚:缺行权价或标的价") + if not is_deep_otm( + option_side=option_side, strike=float(strike), spot=float(spot) + ): + return CloseResult(ok=False, detail="期权非远虚,应走双腿全平") + + fee_rate = self._fee_rate() + perp_side = str(pos["perp_side"]) + perp_qty = float(pos["perp_qty_eth"]) + perp_entry = float(pos["perp_entry_px"]) + pf = perp_fill( + side=perp_side, + action="close", + bid=float(snap.perp.bid), + ask=float(snap.perp.ask), + qty_eth=perp_qty, + fee_rate=fee_rate, + ) + if perp_side == "long": + perp_pnl = (pf.fill_px - perp_entry) * perp_qty + else: + perp_pnl = (perp_entry - pf.fill_px) * perp_qty + self.ledger.apply_cash( + perp_pnl - pf.fee, + kind="close_perp", + group_id=group_id, + note=f"close perp {reason} abandon option", + ) + + 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) + pf.slip + # 暂记永续段实现盈亏;期权到期结算后再按全部成交重算 + interim_net = perp_pnl - open_fees - pf.fee + + 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) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "perp", + "close", + "flat", + s.perp_inst_id, + perp_qty, + None, + pf.base_px, + pf.fill_px, + pf.fee, + pf.slip, + pf.notional, + now, + ), + ) + 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), + expiry_ymd, + expiry_ms, + float(pos["entry_index_px"] or 0), + float(pos["initial_premium"] or 0), + "pending", + now, + f"abandoned after {reason}; deep_otm spot={spot:.4f} K={strike}", + ), + ) + self.db._conn.execute( + """UPDATE groups SET status=?, close_reason=?, realized_pnl=?, + fees=?, slip_cost=?, note=? WHERE group_id=?""", + ( + "option_residual", + reason, + interim_net, + fees, + slip, + f"perp_closed; option residual until expiry", + 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", + data={ + "group_id": group_id, + "reason": reason, + "mode": "target_perp_only", + "perp_pnl": perp_pnl, + "interim_net": interim_net, + "option_abandoned": True, + "strike": float(strike), + "spot": float(spot), + }, + ) + + def list_residual_options(self, *, pending_only: bool = True) -> list[dict[str, Any]]: + if pending_only: + rows = self.db.fetchall( + "SELECT * FROM residual_options WHERE status='pending' ORDER BY created_at_ms ASC" + ) + else: + rows = self.db.fetchall( + "SELECT * FROM residual_options ORDER BY created_at_ms DESC LIMIT 100" + ) + return [dict(r) for r in rows] + + 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) + pending = self.db.fetchall( + "SELECT * FROM residual_options WHERE status='pending' ORDER BY id ASC" + ) + out: list[dict[str, Any]] = [] + for row in pending: + ems = row["expiry_ms"] + if ems is None: + ymd = row["expiry_ymd"] + if ymd: + try: + from ..exchange.expiry import expiry_ms_from_ymd + + ems = int(expiry_ms_from_ymd(str(ymd))) + except Exception: + continue + else: + continue + if now < int(ems): + continue + r = self._settle_one_residual(dict(row), now_ms=now) + if r: + out.append(r) + return out + + def settle_all_residuals_now(self) -> list[dict[str, Any]]: + """紧急:立即按内在价值结算全部残留(不等到期)。""" + pending = self.db.fetchall( + "SELECT * FROM residual_options WHERE status='pending' ORDER BY id ASC" + ) + now = int(time.time() * 1000) + out: list[dict[str, Any]] = [] + for row in pending: + r = self._settle_one_residual(dict(row), now_ms=now, force=True) + if r: + out.append(r) + return out + + def _settle_one_residual( + self, row: dict[str, Any], *, now_ms: int, force: bool = False + ) -> dict[str, Any] | None: + group_id = str(row["group_id"]) + sess = get_session() + snap = sess.snapshot() + spot = self._close_spot_px(snap) + strike = row["strike"] + if strike is None or spot is None: + logger = __import__("logging").getLogger(__name__) + logger.warning("residual settle skip %s: no strike/spot", group_id) + return None + fee_rate = self._fee_rate() + intrinsic = option_intrinsic( + option_side=str(row["option_side"]), + strike=float(strike), + spot=float(spot), + ) + of = option_expiry_settle( + intrinsic=float(intrinsic), + qty_eth=float(row["option_qty_eth"]), + fee_rate=fee_rate, + ) + opt_entry = float(row["option_entry_px"]) + opt_qty = float(row["option_qty_eth"]) + opt_pnl = (of.fill_px - opt_entry) * opt_qty + opt_cash = of.notional - of.fee + self.ledger.apply_cash( + opt_cash, + kind="close_option", + group_id=group_id, + note=f"residual option expiry settle{' force' if force else ''}", + ) + + 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) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "option", + "close", + "flat", + str(row["option_inst_id"]), + opt_qty, + float(row["option_qty_contracts"] or 0), + of.base_px, + of.fill_px, + of.fee, + of.slip, + of.notional, + now_ms, + ), + ) + 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 - of.fee + g = self.db._conn.execute( + "SELECT fees, slip_cost FROM groups WHERE group_id=?", (group_id,) + ).fetchone() + fees = float(g["fees"] or 0) + of.fee + slip = float(g["slip_cost"] or 0) + of.slip + self.db._conn.execute( + """UPDATE residual_options SET status=?, settled_at_ms=?, settle_px=?, settle_pnl=?, note=? + WHERE group_id=?""", + ( + "settled", + now_ms, + of.fill_px, + opt_pnl, + "settled at intrinsic", + group_id, + ), + ) + self.db._conn.execute( + """UPDATE groups SET status=?, close_at_ms=?, realized_pnl=?, fees=?, slip_cost=? + WHERE group_id=?""", + ("closed", now_ms, float(net), fees, slip, group_id), + ) + self.db._conn.commit() + + return { + "group_id": group_id, + "option_pnl": opt_pnl, + "settle_px": of.fill_px, + "net_pnl": net, + "forced": force, + } + def unrealized(self) -> dict[str, Any]: pos = self.current_position() if pos.get("status") != "open": diff --git a/backend/app/sim/pnl.py b/backend/app/sim/pnl.py new file mode 100644 index 0000000..af2f521 --- /dev/null +++ b/backend/app/sim/pnl.py @@ -0,0 +1,67 @@ +"""从成交明细汇总腿盈亏与净盈亏。""" + +from __future__ import annotations + +from typing import Any + + +def summarize_fills_pnl(fills: list[Any]) -> dict[str, float | None]: + """ + 价差盈亏按 fill_px;手续费另扣。 + 净盈亏 = 期权盈亏 + 永续盈亏 − 全部手续费(开+平)。 + 允许只有永续已平、期权尚未结算的半组。 + """ + rows = [dict(x) for x in fills] + opt_open = next( + (f for f in rows if f.get("leg") == "option" and f.get("action") == "open"), + None, + ) + opt_close = next( + (f for f in rows if f.get("leg") == "option" and f.get("action") == "close"), + None, + ) + perp_open = next( + (f for f in rows if f.get("leg") == "perp" and f.get("action") == "open"), + None, + ) + perp_close = next( + (f for f in rows if f.get("leg") == "perp" and f.get("action") == "close"), + None, + ) + + option_pnl: float | None = None + if opt_open and opt_close: + qty = float(opt_open.get("qty_eth") or opt_close.get("qty_eth") or 0) + option_pnl = (float(opt_close["fill_px"]) - float(opt_open["fill_px"])) * qty + + perp_pnl: float | None = None + if perp_open and perp_close: + qty = float(perp_open.get("qty_eth") or perp_close.get("qty_eth") or 0) + side = str(perp_open.get("side") or "") + o = float(perp_open["fill_px"]) + c = float(perp_close["fill_px"]) + if side == "long": + perp_pnl = (c - o) * qty + else: + perp_pnl = (o - c) * qty + + fees_total = sum(float(f.get("fee") or 0) for f in rows) + gross = None + net = None + if option_pnl is not None and perp_pnl is not None: + gross = option_pnl + perp_pnl + net = gross - fees_total + elif option_pnl is not None: + gross = option_pnl + net = option_pnl - fees_total + elif perp_pnl is not None: + gross = perp_pnl + net = perp_pnl - fees_total + + return { + "option_pnl": option_pnl, + "perp_pnl": perp_pnl, + "fees_total": fees_total, + "gross_pnl": gross, + "net_pnl": net, + } diff --git a/backend/app/sim/pricing.py b/backend/app/sim/pricing.py index fe88469..7acccc4 100644 --- a/backend/app/sim/pricing.py +++ b/backend/app/sim/pricing.py @@ -29,6 +29,22 @@ def option_intrinsic(*, option_side: str, strike: float, spot: float) -> float: return 0.0 +def is_deep_otm( + *, + option_side: str, + strike: float, + spot: float, + max_intrinsic: float = 0.01, +) -> bool: + """ + 远虚:内在价值≈0(多头期权已无行权价值)。 + 100×杠杆 ATM 在标的波动约1%后常落入此状态。 + """ + return option_intrinsic( + option_side=option_side, strike=strike, spot=spot + ) <= float(max_intrinsic) + + def option_expiry_settle( *, intrinsic: float, diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 96cb98b..f331f2d 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -88,6 +88,7 @@ class StrategyEngine: "can_open": allow_open, "last_error": last_error, "position": upl, + "residuals": self.matcher.list_residual_options(pending_only=True), "ledger": self.ledger.snapshot(), } @@ -118,16 +119,24 @@ class StrategyEngine: async def emergency_close(self) -> dict[str, Any]: async with self._lock: - # 紧急全平:绕过期权流动性/偏差校验 - r = self.matcher.close_group(reason="emergency", bypass_liquidity=True) - if r.ok: - self._after_close() + close_data: dict[str, Any] | None = None + detail = "flat" + ok = True + pos = self.matcher.current_position() + if pos.get("status") == "open": + 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() + residuals = self.matcher.settle_all_residuals_now() return { "close": { - "ok": r.ok, - "detail": r.detail, - "liquidity_wait": r.liquidity_wait, - "data": r.data, + "ok": ok, + "detail": detail, + "data": close_data, + "residuals_settled": residuals, }, "state": self.state(), } @@ -175,9 +184,27 @@ class StrategyEngine: reason: str, bypass_liquidity: bool, pending_close: bool, + abandon_if_deep_otm: bool = False, ) -> None: 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", + ) + if r.ok: + self._after_close() + self._set_state( + last_error=None, + phase="resting", + ) + else: + self._set_state(phase="closing", last_error=r.detail) + return + r = await asyncio.to_thread( self.matcher.close_group, reason=reason, @@ -186,12 +213,25 @@ class StrategyEngine: if r.ok: self._after_close() elif r.liquidity_wait and not bypass_liquidity: + # 等待期间若已变成远虚,下一 tick 走归档 + if self.matcher.option_is_deep_otm(): + r2 = await asyncio.to_thread( + self.matcher.close_perp_abandon_option, + reason="target_perp_only", + ) + if r2.ok: + self._after_close() + return self._set_state(phase="liquidity_wait", last_error=r.detail) else: self._set_state(phase="closing", last_error=r.detail) + async def _settle_residuals(self) -> None: + await asyncio.to_thread(self.matcher.settle_due_residuals) + async def _maybe_expiry_close(self) -> bool: """若持仓已到期则强制全平。返回是否触发到期平仓。""" + await self._settle_residuals() pos = self.matcher.current_position() if pos.get("status") != "open": return False @@ -206,6 +246,7 @@ class StrategyEngine: reason="expiry", bypass_liquidity=True, pending_close=pending, + abandon_if_deep_otm=False, ) return True @@ -242,6 +283,9 @@ class StrategyEngine: await asyncio.sleep(1) async def _tick_async(self) -> None: + # 残留期权到期结算(与活跃组隔离,不挡开仓) + await self._settle_residuals() + s = get_settings() st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1") assert st is not None @@ -260,7 +304,7 @@ class StrategyEngine: ) pos = self.matcher.current_position() - # 有未平仓:只盯平仓,绝不开下一组 + # 有活跃持仓:只盯当前组平仓;残留期权不在此扫描 if pos.get("status") == "open": upl = self.matcher.unrealized() expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl)) @@ -276,13 +320,17 @@ class StrategyEngine: if expired.should_close: reason = "expiry" bypass = True + abandon = False else: reason = decision.reason or "liquidity_retry" bypass = False + # 目标达标(或流动性等待重试)时:远虚走只平永续 + abandon = bool(decision.should_close or pending_close) await self._close_open_position( reason=reason, bypass_liquidity=bypass, pending_close=pending_close, + abandon_if_deep_otm=abandon, ) else: self._set_state(phase="open", last_error=None) diff --git a/backend/tests/test_p1_p2_rules.py b/backend/tests/test_p1_p2_rules.py index 6e76650..fc10b78 100644 --- a/backend/tests/test_p1_p2_rules.py +++ b/backend/tests/test_p1_p2_rules.py @@ -144,6 +144,18 @@ def test_expiry_close() -> None: assert d3.should_close is True +def test_deep_otm_and_expiry_settle() -> None: + from app.sim.pricing import is_deep_otm, option_expiry_settle, option_intrinsic + + assert is_deep_otm(option_side="call", strike=1860, spot=1840) is True + assert is_deep_otm(option_side="call", strike=1860, spot=1882) is False + assert is_deep_otm(option_side="put", strike=1860, spot=1882) is True + assert option_intrinsic(option_side="call", strike=1860, spot=1840) == 0.0 + settled = option_expiry_settle(intrinsic=0.0, qty_eth=2.0, fee_rate=0.0005) + assert settled.fill_px == 0.0 + assert settled.notional == 0.0 + + def test_option_intrinsic_and_close_bid_floor() -> None: from app.sim.pricing import ( option_expiry_settle, diff --git a/docs/ETH对冲策略说明.mm b/docs/ETH对冲策略说明.mm index 12dcae5..1ffe430 100644 --- a/docs/ETH对冲策略说明.mm +++ b/docs/ETH对冲策略说明.mm @@ -1,4 +1,4 @@ - + diff --git a/docs/ETH对冲策略说明.xmind b/docs/ETH对冲策略说明.xmind index 565bbe0..b929fda 100644 Binary files a/docs/ETH对冲策略说明.xmind and b/docs/ETH对冲策略说明.xmind differ diff --git a/docs/策略说明.md b/docs/策略说明.md index 37220ad..7e1bf35 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -101,11 +101,12 @@ ## 4. 平仓机制 -任一触发 → **该组永续 + 期权全部平掉**(先期权、后永续)。 +平仓分两大类:**① 目标平仓**(净盈利达标);**② 到期平仓**(目标未达标,拖到到期)。 +目标平仓又分两种落地方式(双腿全平 / 只平永续+期权归档到期)。 -### 4.1 净盈利达标(主出场) +### 4.1 目标平仓(第一优先级) -**净盈利**口径(盯盘用): +**净盈利**口径(盯盘用,主要看买一,不看标记价): ``` 净盈利 ≈ 永续浮盈 + 期权浮盈 − 预估平仓手续费 @@ -119,32 +120,72 @@ 说明:权利金倍数模式下,**1× = 赚回一倍权利金金额的净利**(例如权利金 20.41,目标约 20.41,不是 40.82)。 -### 4.2 到期自动全平 +达标后按期权状态走下面 **A / B** 两套(不是互相替换,按能否平掉期权分支): +#### 4.1.A 双腿全平(期权仍有可平价值 / 非远虚) + +- 条件:净利达标,且期权 **不是远虚**(仍可能有买一可平)。 +- 动作:校验买一深度 + 买一/标记偏差(默认 ≤30%)→ **先平期权、再平永续**。 +- 通不过流动性 → `liquidity_wait`,继续等待(不改开仓组)。 + +#### 4.1.B 只平永续 + 期权归档到期(远虚) + +筛选约 **100× 杠杆** 期权时,标的波动约 **1%** 量级后,买方腿常变为**远虚**(内在价值≈0),买一往往枯死、整组硬平会卡死。 + +- 条件:净利达标,且期权已是 **虚值且远虚**(内在价值 ≈ 0)。 +- 动作: + 1. **只市价平掉永续**,兑现净利里永续那一截; + 2. 本张期权 **不再盯盘、不再参与平仓扫描**,归档为「到期残留」; + 3. 因期权 **逐仓**,残留 **不占用活跃持仓**,**不挡住下一组开仓**; + 4. 下一组开平仓 **只扫当前活跃组期权**,不扫描历史残留腿; + 5. 残留期权到到期日再按 **内在价值** 单独结算(多半接近 0)。 + +> 直觉:能双腿平就走 A;期权已经远虚平不掉就走 B,别把永续利润和下一单机会一起卡死。 + +### 4.2 到期平仓(目标未达标) + +- 净利一直未达标 → **不主动砍仓**,持有到期权到期。 - 到达期权到期时刻(OKX:UTC 08:00 = **上海 16:00**)→ 原因 `expiry`。 -- **对齐实盘**:期权按标的结算价的 **内在价值** 入账(不吃盘口、无价差滑点);永续仍市价平掉。 -- 策略 **暂停时仍执行**,避免拖过期。 +- **活跃组**:期权按标的结算价的 **内在价值** 入账;若永续仍在则市价平掉。 +- **残留组**(曾走 4.1.B):仅结算归档期权,不碰当前活跃仓。 +- 策略 **暂停时仍执行到期结算**,避免拖过期。 - 波动小、拖到到期:权利金亏损视为 **预算内成本**,可接受。 -### 4.3 平仓流动性闸门(常规出场) +### 4.3 平仓流动性闸门(仅 4.1.A) -常规净盈利平仓时: +双腿全平时: - 期权买一深度需覆盖平仓名义; - 买一相对标记偏差默认 ≤ **30%**(`close_bid_mark_max_pct`); -- 不满足 → `liquidity_wait`,继续等待,不改开仓。 +- 不满足 → `liquidity_wait`,继续等待。 -**紧急全平**:绕过上述闸门;期权价取 max(买一, 标记, 内在价值)。 +**4.1.B / 到期 / 紧急全平**:不适用「必须卖掉期权买一」这套闸门(到期与残留按内在价值;紧急可绕过)。 -### 4.4 其它平仓入口 +**紧急全平**:活跃组尽量双腿平掉;残留期权一并按内在价值结算。 + +### 4.4 其它平仓入口 / 原因码 | 原因码 | 含义 | |--------|------| -| `fixed_usdt` / `premium_multiple` | 净盈利达标 | -| `expiry` | 到期自动全平 | +| `fixed_usdt` / `premium_multiple` | 目标平仓 · 双腿全平(4.1.A) | +| `target_perp_only` | 目标平仓 · 只平永续,期权归档到期(4.1.B) | +| `expiry` | 到期结算(活跃组或残留期权) | | `emergency` | 界面紧急全平 | | `manual` | 手动平仓 | +### 4.5 流程总览 + +``` +有活跃持仓 + ├─ 净利达标? + │ ├─ 是 · 期权远虚(内在价值≈0)→ 平永续,期权归档到期,可开下一组 + │ └─ 是 · 非远虚 → 试双腿全平(流动性闸门)→ 失败则等待 + └─ 否 → 持有直到到期 → 内在价值结算(+ 若有永续则平永续) + +残留期权(已归档) + └─ 仅到期结算;不参与盯盘、不参与下一组平仓扫描 +``` + --- ## 5. 策略侧重点 @@ -155,8 +196,9 @@ | **概率与轮次** | 波动日可能多轮;磨盘日可能零轮或一轮认亏 | | **确定性时间规则** | 用周末跳过、到期全平,**不做**难以精准的横盘识别 | | **费用后净利** | 出场看扣完预估平仓费后的净盈利,避免「账面赚、平完亏」 | -| **单组纪律** | 有仓不开下一组,防止重叠风险 | +| **单组纪律** | 活跃持仓最多 1 组;远虚归档的残留期权不挡下一组(逐仓) | | **权利金是成本预算** | 小波动拖到期亏权利金,属于策略设计内损耗,不是异常 bug | +| **目标平仓两分支** | 能平期权则双腿全平;远虚则只平永续、期权到期归档 | 适合对外表述: @@ -173,7 +215,7 @@ | **横盘 / 低波动** | 高 | Theta 消耗权利金;永续几乎不贡献利润 | 周末跳过;到期认亏全平;接受磨损日 | | **方向选错** | 中高 | 卖一比价并非预测圣杯;错向时期权可能失利,永续端也可能承压 | 净盈利达标才兑现;到期强平止损时间 | | **开平滑点与手续费** | 中 | 双边 taker + 滑点侵蚀小目标利润 | 目标默认 15U 需覆盖费用;勿把目标设过低 | -| **期权流动性** | 中 | 平仓买一薄、偏差大 → 等待 | 流动性闸门;到期/紧急可绕过 | +| **期权流动性** | 中 | 远虚时买一枯死 | 4.1.B 只平永续+归档;到期内在价值结算 | | **杠杆与保证金** | 中 | 永续 3×,标的急跌/急涨挤压保证金 | 控制名义、保留资金缓冲;实盘注意强平线 | | **同到期 ATM 拥挤** | 低–中 | 临近到期价差与盘口恶化 | `min_option_hours`;到期强平 | | **SIM≠实盘** | 高(若直接外推) | 本地撮合、深度与成交假设简化 | 小资金实盘验证后再放大 | @@ -258,3 +300,4 @@ | 2026-07-25 | 初稿:对齐当前开平仓、周末跳过、到期全平、净盈利口径与资金建议 | | 2026-07-25 | 平仓顺序改为先期权后永续(与开仓同理:薄腿优先) | | 2026-07-26 | 到期按内在价值结算(对齐实盘);紧急平仓仍用 max(买一,标记,内在价值) | +| 2026-07-26 | 明确两套目标平仓:双腿全平 / 远虚只平永续+期权归档到期;到期为未达标路径 | diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c2b7d4e..d040705 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -166,6 +166,14 @@ export type PlanState = { initial_premium?: number; premium_gap?: number; }; + residuals?: { + group_id: string; + option_inst_id: string; + option_side: string; + strike: number | null; + expiry_ymd: string | null; + status: string; + }[]; ledger: { equity: number; available: number; reserved: number }; }; diff --git a/frontend/src/labels.ts b/frontend/src/labels.ts index 2eb5b74..73adf4c 100644 --- a/frontend/src/labels.ts +++ b/frontend/src/labels.ts @@ -3,6 +3,7 @@ const STATUS_ZH: Record = { open: "持仓中", closed: "已平仓", + option_residual: "期权残留待到期", }; const SIDE_ZH: Record = { @@ -23,9 +24,10 @@ const ACTION_ZH: Record = { }; const CLOSE_REASON_ZH: Record = { - fixed_usdt: "固定净盈利达标", - premium_multiple: "权利金倍数达标", - expiry: "到期自动全平", + fixed_usdt: "固定净盈利达标·双腿全平", + premium_multiple: "权利金倍数达标·双腿全平", + target_perp_only: "净盈利达标·只平永续(期权归档)", + expiry: "到期结算", emergency: "紧急全平", manual: "手动平仓", liquidity_retry: "流动性等待后续平仓", diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 3f9b9cf..f9d851c 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -134,7 +134,8 @@ export default function PlanPage() {

SIM 本地撮合 · 行情{" "} {(snap?.exchange || "okx").toUpperCase()} - {snap?.perp_inst_id ? ` · ${snap.perp_inst_id}` : ""} · 净盈利达标或到期全平 + {snap?.perp_inst_id ? ` · ${snap.perp_inst_id}` : ""} · + 目标平仓(双腿/远虚只平永续) · 未达标则到期结算

{err ?
{err}
: null} @@ -228,6 +229,19 @@ export default function PlanPage() { 当前组 {pos?.group_id || "—"} +
+ 残留期权(待到期) + + {plan?.residuals && plan.residuals.length > 0 + ? plan.residuals + .map( + (r) => + `${r.group_id}:${r.option_inst_id || "?"}@${r.expiry_ymd || "?"}` + ) + .join(" · ") + : "无"} + +
信号方向 {biasTag} diff --git a/scripts/gen_strategy_xmind.py b/scripts/gen_strategy_xmind.py index f6e2928..c869e3f 100644 --- a/scripts/gen_strategy_xmind.py +++ b/scripts/gen_strategy_xmind.py @@ -395,41 +395,35 @@ def bodies() -> dict[str, list[str]]: topic( "平仓", [ - L("触发即全平 · 先期权后永续"), + L("两大类:目标平仓 · 到期平仓"), G( - "主出场", + "目标A · 双腿全平", [ - "净利 = 双腿浮盈 − 预估平仓费", - "默认目标 ≥ 15 USDT", - "或 ≥ 权利金 × 1.0", + "净利达标 + 期权非远虚", + "先期权后永续", + "流动性闸门:深度 + 标记偏差30%", ], ), G( - "流动性闸门", + "目标B · 只平永续", [ - "买一深度足够", - "买一偏离标记 ≤ 30%", - "不满足则等待", + "净利达标 + 期权远虚(内在约0)", + "100x杠杆下波动约1%常远虚", + "期权归档到期 · 不再盯盘", + "逐仓不挡下一组开仓", + "下一组不扫描残留期权", ], ), G( - "到期全平", + "到期平仓", [ - "上海 16:00(OKX)", - "绕过闸门 · 暂停也执行", - "亏权利金 = 预算成本", + "目标未达标 → 持有到到期", + "期权按内在价值结算", + "活跃组若有永续一并平", + "残留组只结归档期权", ], ), L("另有:紧急全平 / 手动平仓"), - G( - "执行", - [ - "① 确认期权买一", - "② 平期权入账", - "③ 瞬时平永续", - "④ 失败则回滚", - ], - ), ], style_id="sty-ops_close", marker="arrow-down", @@ -551,15 +545,15 @@ def mm_flat() -> list[str]: mm("心法与节奏", [mm(x) for x in ["有波动多做", "目标 15U", "休息 5 分钟", "最多 1 组"]]), mm("仓位结构", [mm(x) for x in ["永续 1 · 期权 2", "先期权后永续", "ATM 选向"]]), mm("开仓", [mm(x) for x in ["前置检查", "选约门槛", "四步执行"]]), - mm("平仓", [mm(x) for x in ["净利达标", "流动性闸门", "到期全平"]]), + mm("平仓", [mm(x) for x in ["目标A双腿", "目标B只平永续", "到期结算"]]), mm("关键参数", [mm(x) for x in ["仓位/杠杆/费率", "目标/休息/周末", "选约/偏差"]]), ], ), - mm("⑥ 风险评估", [mm(x) for x in ["横盘磨损", "SIM≠实盘", "方向/流动性/保证金", "不做卖方与叠仓"]]), + mm("⑥ 风险评估", [mm(x) for x in ["横盘磨损", "SIM≠实盘", "方向/流动性/保证金", "残留不挡新开"]]), mm("⑦ 纪律要求", [mm(x) for x in [ - "单组 · 休息 · 周末", - "不硬砸 · 看净利", - "不报复 · 不叠仓", + "活跃1组 · 残留可共存", + "远虚归档 · 不挡新开", + "看净利 · 不报复", "SIM 后再放大", ]]), ]