Add option close liquidity gate with 30% bid/mark cap.

Block new opens while flat legs wait; emergency close bypasses the check.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-25 09:05:04 +08:00
parent 8c14759e78
commit 9fd2a842af
11 changed files with 136 additions and 28 deletions
+1
View File
@@ -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
+6
View File
@@ -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
),
+2
View File
@@ -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:
+1
View File
@@ -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
+1
View File
@@ -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),
}
+30 -1
View File
@@ -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, ""
+50 -21
View File
@@ -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,
)
+17 -6
View File
@@ -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:
+12
View File
@@ -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
+1
View File
@@ -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 };
+15
View File
@@ -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))}
/>
</div>
<div className="field">
<label htmlFor="closeDev">/%</label>
<input
id="closeDev"
className="mono"
type="number"
step="1"
min="1"
value={closeDevPct}
onChange={(e) => setCloseDevPct(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="perp"> ETH </label>
<input