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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user