diff --git a/.env.example b/.env.example index 540d191..b65affa 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,7 @@ LEVERAGE=3 MIN_OPTION_HOURS=12 MIN_OPTION_LEVERAGE=100 EXIT_MOVE_PCT=2 +CLOSE_BID_MARK_MAX_PCT=30 REST_SECONDS=300 PERP_QTY_ETH=1 OPTION_QTY_ETH=2 diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 9ea9ce7..87570eb 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -20,6 +20,7 @@ KEYS = ( "leverage", "min_option_hours", "min_option_leverage", + "close_bid_mark_max_pct", "perp_qty_eth", "option_qty_eth", ) @@ -33,6 +34,7 @@ class StrategySettingsBody(BaseModel): leverage: float | None = Field(default=None, ge=1, le=125) min_option_hours: float | None = Field(default=None, ge=1, le=720) min_option_leverage: float | None = Field(default=None, ge=1, le=10000) + close_bid_mark_max_pct: float | None = Field(default=None, ge=1, le=100) perp_qty_eth: float | None = Field(default=None, ge=0.01, le=100) option_qty_eth: float | None = Field(default=None, ge=0.01, le=100) @@ -60,6 +62,10 @@ def _read_settings() -> dict: db.get_setting("min_option_leverage", str(s.min_option_leverage)) or s.min_option_leverage ), + "close_bid_mark_max_pct": float( + db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct)) + or s.close_bid_mark_max_pct + ), "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/api/sim.py b/backend/app/api/sim.py index 0c795c7..561d00c 100644 --- a/backend/app/api/sim.py +++ b/backend/app/api/sim.py @@ -37,6 +37,8 @@ async def sim_open_group( _user: Annotated[str, Depends(require_user)], body: ManualOpenBody | None = None, ) -> dict: + if Matcher().has_open_position(): + raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组") gw = get_gateway() pick = await gw.pick_for_open_async() if pick is None: diff --git a/backend/app/config.py b/backend/app/config.py index 1568e7b..ab64219 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -48,6 +48,7 @@ class Settings(BaseSettings): leverage: float = 3.0 # 永续杠杆 min_option_hours: float = 12.0 # 期权最小剩余小时 min_option_leverage: float = 100.0 # 现价/卖一权利金 下限 + close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差% perp_qty_eth: float = 1.0 option_qty_eth: float = 2.0 option_ct_mult_default: float = 0.01 diff --git a/backend/app/models/db.py b/backend/app/models/db.py index 23fdf67..9a7a10d 100644 --- a/backend/app/models/db.py +++ b/backend/app/models/db.py @@ -155,6 +155,7 @@ class Database: "leverage": str(s.leverage), "min_option_hours": str(s.min_option_hours), "min_option_leverage": str(s.min_option_leverage), + "close_bid_mark_max_pct": str(s.close_bid_mark_max_pct), "perp_qty_eth": str(s.perp_qty_eth), "option_qty_eth": str(s.option_qty_eth), } diff --git a/backend/app/sim/liquidity.py b/backend/app/sim/liquidity.py index 71df146..cea9bc0 100644 --- a/backend/app/sim/liquidity.py +++ b/backend/app/sim/liquidity.py @@ -1,4 +1,4 @@ -"""期权买一流动性:张数 × ctMult 是否覆盖名义 ETH。""" +"""期权买一流动性:深度覆盖 + 买一相对标记偏差。""" from __future__ import annotations @@ -17,3 +17,32 @@ def bid_covers_eth(*, bid_sz_contracts: float | None, ct_mult: float, need_eth: if bid_sz_contracts is None or bid_sz_contracts <= 0: return False return eth_from_contracts(bid_sz_contracts, ct_mult) + 1e-12 >= float(need_eth) + + +def bid_mark_deviation_pct(bid: float | None, mark: float | None) -> float | None: + """|bid-mark|/mark * 100;无法计算返回 None。""" + if bid is None or mark is None or mark <= 0 or bid < 0: + return None + return abs(float(bid) - float(mark)) / float(mark) * 100.0 + + +def bid_mark_ok( + *, + bid: float | None, + mark: float | None, + max_dev_pct: float, +) -> tuple[bool, str]: + """ + 买一相对标记偏差是否可接受。 + max_dev_pct: 百分数,如 30 表示 30%。 + """ + if bid is None: + return False, "期权买一不可用" + if mark is None or mark <= 0: + return False, "期权标记价不可用,等待" + dev = bid_mark_deviation_pct(bid, mark) + if dev is None: + return False, "无法计算买一/标记偏差" + if dev > float(max_dev_pct) + 1e-9: + return False, f"买一相对标记偏差 {dev:.1f}% > {max_dev_pct:.0f}%,等待" + return True, "" diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 2836a75..4491e69 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -11,7 +11,7 @@ from ..exchange import get_exchange from ..models.db import Database, get_db from ..strategy.session import get_session from .ledger import Ledger -from .liquidity import bid_covers_eth, contracts_for_eth +from .liquidity import bid_covers_eth, bid_mark_ok, contracts_for_eth from .pricing import option_fill, perp_fill @@ -54,6 +54,18 @@ class Matcher: assert row is not None return dict(row) + def has_open_position(self) -> bool: + pos = self.current_position() + return pos.get("status") == "open" and bool(pos.get("group_id")) + + def _liquidity_wait(self, group_id: str, detail: str) -> CloseResult: + note = f"liquidity_wait:{int(time.time())}:{detail[:80]}" + self.db.execute( + "UPDATE groups SET note=? WHERE group_id=? AND status='open'", + (note, group_id), + ) + return CloseResult(ok=False, detail=detail, liquidity_wait=True) + def open_group( self, *, @@ -219,7 +231,11 @@ class Matcher: }, ) - def close_group(self, *, reason: str) -> CloseResult: + def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult: + """ + 全平一组。默认校验期权买一深度 + 买一/标记偏差(默认≤30%)。 + bypass_liquidity=True:紧急全平可绕过(仍需有可用买一价才能成交;无买一时用标记近似)。 + """ s = get_settings() pos = self.current_position() if pos.get("status") != "open" or not pos.get("group_id"): @@ -236,27 +252,40 @@ class Matcher: oq = get_exchange().quote(option_inst_id) or ( snap.call if option_side == "call" else snap.put ) - if not oq or oq.bid is None: - return CloseResult(ok=False, detail="期权买一不可用", liquidity_wait=True) + if not oq: + return CloseResult( + ok=False, + detail="期权盘口不可用", + liquidity_wait=not bypass_liquidity, + ) ct_mult = self._ct_mult(option_inst_id) need_eth = float(pos["option_qty_eth"] or s.option_qty_eth) - if not bid_covers_eth( - bid_sz_contracts=oq.bid_sz, - ct_mult=ct_mult, - need_eth=need_eth, - ): - # 记流动性不足到组 note,不改变仓位 - note = f"liquidity_wait:{int(time.time())}" - self.db.execute( - "UPDATE groups SET note=? WHERE group_id=? AND status='open'", - (note, group_id), - ) - return CloseResult( - ok=False, - detail="期权买一流动性不足", - liquidity_wait=True, + max_dev = self.ledger.get_setting_float( + "close_bid_mark_max_pct", s.close_bid_mark_max_pct + ) + + close_bid = oq.bid + if not bypass_liquidity: + if close_bid is None: + return self._liquidity_wait(group_id, "期权买一不可用") + if not bid_covers_eth( + bid_sz_contracts=oq.bid_sz, + ct_mult=ct_mult, + need_eth=need_eth, + ): + return self._liquidity_wait(group_id, "期权买一流动性不足") + ok_dev, why = bid_mark_ok( + bid=close_bid, mark=oq.mark_px, max_dev_pct=max_dev ) + if not ok_dev: + return self._liquidity_wait(group_id, why) + else: + # 紧急:优先买一,否则用标记价近似成交(SIM) + if close_bid is None: + close_bid = oq.mark_px + if close_bid is None: + return CloseResult(ok=False, detail="紧急全平失败:无买一/标记价") fee_rate = self._fee_rate() perp_side = str(pos["perp_side"]) @@ -275,8 +304,8 @@ class Matcher: ) of = option_fill( action="close", - bid=float(oq.bid), - ask=float(oq.ask or oq.bid), + bid=float(close_bid), + ask=float(oq.ask or close_bid), qty_eth=opt_qty, fee_rate=fee_rate, ) diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 0910650..1cbaa31 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -88,7 +88,8 @@ class StrategyEngine: async def emergency_close(self) -> dict[str, Any]: async with self._lock: - r = self.matcher.close_group(reason="emergency") + # 紧急全平:绕过期权流动性/偏差校验 + r = self.matcher.close_group(reason="emergency", bypass_liquidity=True) if r.ok: self._after_close() return { @@ -155,8 +156,8 @@ class StrategyEngine: exit_pct = self.ledger.get_setting_float("exit_move_pct", s.exit_move_pct) pos = self.matcher.current_position() + # 有未平仓:只盯平仓,绝不开下一组 if pos.get("status") == "open": - self._set_state(phase="open", last_error=None) upl = self.matcher.unrealized() decision = check_exits( perp_upl=float(upl["perp_upl"]), @@ -164,17 +165,22 @@ class StrategyEngine: move_pct=float(upl.get("move_pct") or 0), exit_move_pct=exit_pct, ) - if decision.should_close: - self._set_state(phase="closing") + pending_close = st["phase"] in ("liquidity_wait", "closing") + if decision.should_close or pending_close: + reason = decision.reason or "liquidity_retry" + if not pending_close: + self._set_state(phase="closing", last_error=None) r = await asyncio.to_thread( - self.matcher.close_group, reason=decision.reason + self.matcher.close_group, reason=reason, bypass_liquidity=False ) if r.ok: self._after_close() elif r.liquidity_wait: self._set_state(phase="liquidity_wait", last_error=r.detail) else: - self._set_state(last_error=r.detail) + self._set_state(phase="closing", last_error=r.detail) + else: + self._set_state(phase="open", last_error=None) return if st["phase"] == "resting" and st["rest_until_ms"]: @@ -190,6 +196,11 @@ class StrategyEngine: if st["phase"] in ("stopped", "outside_window"): self._set_state(phase="idle") + # 双保险:账本仍显示有仓则不开 + if self.matcher.has_open_position(): + self._set_state(phase="open", last_error="有未平仓,禁止开下一组") + return + self._set_state(phase="wait_signal") pick = await get_session().pick_for_open_async() if pick is None: diff --git a/backend/tests/test_p1_p2_rules.py b/backend/tests/test_p1_p2_rules.py index 4bf8e0d..590066f 100644 --- a/backend/tests/test_p1_p2_rules.py +++ b/backend/tests/test_p1_p2_rules.py @@ -1,6 +1,7 @@ from datetime import datetime from zoneinfo import ZoneInfo +from app.sim.liquidity import bid_mark_ok from app.sim.pricing import option_fill, perp_fill from app.strategy.clock import can_open_new, window_key from app.strategy.exits import check_exits @@ -61,3 +62,14 @@ def test_window_always_open() -> None: n2 = datetime(2026, 7, 24, 10, 0, tzinfo=_SH) assert can_open_new(n2) is True assert window_key(n2) == "20260724" + + +def test_bid_mark_deviation_30pct() -> None: + # |7-10|/10 = 30% → 允许(≤30%) + ok, _ = bid_mark_ok(bid=7.0, mark=10.0, max_dev_pct=30) + assert ok is True + ok2, _ = bid_mark_ok(bid=6.9, mark=10.0, max_dev_pct=30) + assert ok2 is False + ok3, why = bid_mark_ok(bid=None, mark=10.0, max_dev_pct=30) + assert ok3 is False + assert "买一" in why diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8b0f23d..0952d34 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -165,6 +165,7 @@ export type StrategySettings = { leverage: number; min_option_hours: number; min_option_leverage: number; + close_bid_mark_max_pct: number; perp_qty_eth: number; option_qty_eth: number; ledger: { equity: number; available: number }; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 49e2706..19f1e96 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -26,6 +26,7 @@ export default function SettingsPage() { const [leverage, setLeverage] = useState(3); const [minHours, setMinHours] = useState(12); const [minOptLev, setMinOptLev] = useState(100); + const [closeDevPct, setCloseDevPct] = useState(30); const [perpQty, setPerpQty] = useState(1); const [optQty, setOptQty] = useState(2); const [stratOk, setStratOk] = useState(""); @@ -39,6 +40,7 @@ export default function SettingsPage() { setLeverage(s.leverage ?? 3); setMinHours(s.min_option_hours ?? 12); setMinOptLev(s.min_option_leverage ?? 100); + setCloseDevPct(s.close_bid_mark_max_pct ?? 30); setPerpQty(s.perp_qty_eth ?? 1); setOptQty(s.option_qty_eth ?? 2); }) @@ -90,6 +92,7 @@ export default function SettingsPage() { leverage, min_option_hours: minHours, min_option_leverage: minOptLev, + close_bid_mark_max_pct: closeDevPct, perp_qty_eth: perpQty, option_qty_eth: optQty, }), @@ -179,6 +182,18 @@ export default function SettingsPage() { onChange={(e) => setExitPct(Number(e.target.value))} /> +