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_HOURS=12
|
||||||
MIN_OPTION_LEVERAGE=100
|
MIN_OPTION_LEVERAGE=100
|
||||||
EXIT_MOVE_PCT=2
|
EXIT_MOVE_PCT=2
|
||||||
|
CLOSE_BID_MARK_MAX_PCT=30
|
||||||
REST_SECONDS=300
|
REST_SECONDS=300
|
||||||
PERP_QTY_ETH=1
|
PERP_QTY_ETH=1
|
||||||
OPTION_QTY_ETH=2
|
OPTION_QTY_ETH=2
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ KEYS = (
|
|||||||
"leverage",
|
"leverage",
|
||||||
"min_option_hours",
|
"min_option_hours",
|
||||||
"min_option_leverage",
|
"min_option_leverage",
|
||||||
|
"close_bid_mark_max_pct",
|
||||||
"perp_qty_eth",
|
"perp_qty_eth",
|
||||||
"option_qty_eth",
|
"option_qty_eth",
|
||||||
)
|
)
|
||||||
@@ -33,6 +34,7 @@ class StrategySettingsBody(BaseModel):
|
|||||||
leverage: float | None = Field(default=None, ge=1, le=125)
|
leverage: float | None = Field(default=None, ge=1, le=125)
|
||||||
min_option_hours: float | None = Field(default=None, ge=1, le=720)
|
min_option_hours: float | None = Field(default=None, ge=1, le=720)
|
||||||
min_option_leverage: float | None = Field(default=None, ge=1, le=10000)
|
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)
|
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)
|
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))
|
db.get_setting("min_option_leverage", str(s.min_option_leverage))
|
||||||
or 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(
|
"perp_qty_eth": float(
|
||||||
db.get_setting("perp_qty_eth", str(s.perp_qty_eth)) or s.perp_qty_eth
|
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)],
|
_user: Annotated[str, Depends(require_user)],
|
||||||
body: ManualOpenBody | None = None,
|
body: ManualOpenBody | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
if Matcher().has_open_position():
|
||||||
|
raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组")
|
||||||
gw = get_gateway()
|
gw = get_gateway()
|
||||||
pick = await gw.pick_for_open_async()
|
pick = await gw.pick_for_open_async()
|
||||||
if pick is None:
|
if pick is None:
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class Settings(BaseSettings):
|
|||||||
leverage: float = 3.0 # 永续杠杆
|
leverage: float = 3.0 # 永续杠杆
|
||||||
min_option_hours: float = 12.0 # 期权最小剩余小时
|
min_option_hours: float = 12.0 # 期权最小剩余小时
|
||||||
min_option_leverage: float = 100.0 # 现价/卖一权利金 下限
|
min_option_leverage: float = 100.0 # 现价/卖一权利金 下限
|
||||||
|
close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差%
|
||||||
perp_qty_eth: float = 1.0
|
perp_qty_eth: float = 1.0
|
||||||
option_qty_eth: float = 2.0
|
option_qty_eth: float = 2.0
|
||||||
option_ct_mult_default: float = 0.01
|
option_ct_mult_default: float = 0.01
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ class Database:
|
|||||||
"leverage": str(s.leverage),
|
"leverage": str(s.leverage),
|
||||||
"min_option_hours": str(s.min_option_hours),
|
"min_option_hours": str(s.min_option_hours),
|
||||||
"min_option_leverage": str(s.min_option_leverage),
|
"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),
|
"perp_qty_eth": str(s.perp_qty_eth),
|
||||||
"option_qty_eth": str(s.option_qty_eth),
|
"option_qty_eth": str(s.option_qty_eth),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""期权买一流动性:张数 × ctMult 是否覆盖名义 ETH。"""
|
"""期权买一流动性:深度覆盖 + 买一相对标记偏差。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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:
|
if bid_sz_contracts is None or bid_sz_contracts <= 0:
|
||||||
return False
|
return False
|
||||||
return eth_from_contracts(bid_sz_contracts, ct_mult) + 1e-12 >= float(need_eth)
|
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 ..models.db import Database, get_db
|
||||||
from ..strategy.session import get_session
|
from ..strategy.session import get_session
|
||||||
from .ledger import Ledger
|
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
|
from .pricing import option_fill, perp_fill
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +54,18 @@ class Matcher:
|
|||||||
assert row is not None
|
assert row is not None
|
||||||
return dict(row)
|
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(
|
def open_group(
|
||||||
self,
|
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()
|
s = get_settings()
|
||||||
pos = self.current_position()
|
pos = self.current_position()
|
||||||
if pos.get("status") != "open" or not pos.get("group_id"):
|
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 (
|
oq = get_exchange().quote(option_inst_id) or (
|
||||||
snap.call if option_side == "call" else snap.put
|
snap.call if option_side == "call" else snap.put
|
||||||
)
|
)
|
||||||
if not oq or oq.bid is None:
|
if not oq:
|
||||||
return CloseResult(ok=False, detail="期权买一不可用", liquidity_wait=True)
|
return CloseResult(
|
||||||
|
ok=False,
|
||||||
|
detail="期权盘口不可用",
|
||||||
|
liquidity_wait=not bypass_liquidity,
|
||||||
|
)
|
||||||
|
|
||||||
ct_mult = self._ct_mult(option_inst_id)
|
ct_mult = self._ct_mult(option_inst_id)
|
||||||
need_eth = float(pos["option_qty_eth"] or s.option_qty_eth)
|
need_eth = float(pos["option_qty_eth"] or s.option_qty_eth)
|
||||||
if not bid_covers_eth(
|
max_dev = self.ledger.get_setting_float(
|
||||||
bid_sz_contracts=oq.bid_sz,
|
"close_bid_mark_max_pct", s.close_bid_mark_max_pct
|
||||||
ct_mult=ct_mult,
|
)
|
||||||
need_eth=need_eth,
|
|
||||||
):
|
close_bid = oq.bid
|
||||||
# 记流动性不足到组 note,不改变仓位
|
if not bypass_liquidity:
|
||||||
note = f"liquidity_wait:{int(time.time())}"
|
if close_bid is None:
|
||||||
self.db.execute(
|
return self._liquidity_wait(group_id, "期权买一不可用")
|
||||||
"UPDATE groups SET note=? WHERE group_id=? AND status='open'",
|
if not bid_covers_eth(
|
||||||
(note, group_id),
|
bid_sz_contracts=oq.bid_sz,
|
||||||
)
|
ct_mult=ct_mult,
|
||||||
return CloseResult(
|
need_eth=need_eth,
|
||||||
ok=False,
|
):
|
||||||
detail="期权买一流动性不足",
|
return self._liquidity_wait(group_id, "期权买一流动性不足")
|
||||||
liquidity_wait=True,
|
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()
|
fee_rate = self._fee_rate()
|
||||||
perp_side = str(pos["perp_side"])
|
perp_side = str(pos["perp_side"])
|
||||||
@@ -275,8 +304,8 @@ class Matcher:
|
|||||||
)
|
)
|
||||||
of = option_fill(
|
of = option_fill(
|
||||||
action="close",
|
action="close",
|
||||||
bid=float(oq.bid),
|
bid=float(close_bid),
|
||||||
ask=float(oq.ask or oq.bid),
|
ask=float(oq.ask or close_bid),
|
||||||
qty_eth=opt_qty,
|
qty_eth=opt_qty,
|
||||||
fee_rate=fee_rate,
|
fee_rate=fee_rate,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ class StrategyEngine:
|
|||||||
|
|
||||||
async def emergency_close(self) -> dict[str, Any]:
|
async def emergency_close(self) -> dict[str, Any]:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
r = self.matcher.close_group(reason="emergency")
|
# 紧急全平:绕过期权流动性/偏差校验
|
||||||
|
r = self.matcher.close_group(reason="emergency", bypass_liquidity=True)
|
||||||
if r.ok:
|
if r.ok:
|
||||||
self._after_close()
|
self._after_close()
|
||||||
return {
|
return {
|
||||||
@@ -155,8 +156,8 @@ class StrategyEngine:
|
|||||||
exit_pct = self.ledger.get_setting_float("exit_move_pct", s.exit_move_pct)
|
exit_pct = self.ledger.get_setting_float("exit_move_pct", s.exit_move_pct)
|
||||||
pos = self.matcher.current_position()
|
pos = self.matcher.current_position()
|
||||||
|
|
||||||
|
# 有未平仓:只盯平仓,绝不开下一组
|
||||||
if pos.get("status") == "open":
|
if pos.get("status") == "open":
|
||||||
self._set_state(phase="open", last_error=None)
|
|
||||||
upl = self.matcher.unrealized()
|
upl = self.matcher.unrealized()
|
||||||
decision = check_exits(
|
decision = check_exits(
|
||||||
perp_upl=float(upl["perp_upl"]),
|
perp_upl=float(upl["perp_upl"]),
|
||||||
@@ -164,17 +165,22 @@ class StrategyEngine:
|
|||||||
move_pct=float(upl.get("move_pct") or 0),
|
move_pct=float(upl.get("move_pct") or 0),
|
||||||
exit_move_pct=exit_pct,
|
exit_move_pct=exit_pct,
|
||||||
)
|
)
|
||||||
if decision.should_close:
|
pending_close = st["phase"] in ("liquidity_wait", "closing")
|
||||||
self._set_state(phase="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(
|
r = await asyncio.to_thread(
|
||||||
self.matcher.close_group, reason=decision.reason
|
self.matcher.close_group, reason=reason, bypass_liquidity=False
|
||||||
)
|
)
|
||||||
if r.ok:
|
if r.ok:
|
||||||
self._after_close()
|
self._after_close()
|
||||||
elif r.liquidity_wait:
|
elif r.liquidity_wait:
|
||||||
self._set_state(phase="liquidity_wait", last_error=r.detail)
|
self._set_state(phase="liquidity_wait", last_error=r.detail)
|
||||||
else:
|
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
|
return
|
||||||
|
|
||||||
if st["phase"] == "resting" and st["rest_until_ms"]:
|
if st["phase"] == "resting" and st["rest_until_ms"]:
|
||||||
@@ -190,6 +196,11 @@ class StrategyEngine:
|
|||||||
if st["phase"] in ("stopped", "outside_window"):
|
if st["phase"] in ("stopped", "outside_window"):
|
||||||
self._set_state(phase="idle")
|
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")
|
self._set_state(phase="wait_signal")
|
||||||
pick = await get_session().pick_for_open_async()
|
pick = await get_session().pick_for_open_async()
|
||||||
if pick is None:
|
if pick is None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from app.sim.liquidity import bid_mark_ok
|
||||||
from app.sim.pricing import option_fill, perp_fill
|
from app.sim.pricing import option_fill, perp_fill
|
||||||
from app.strategy.clock import can_open_new, window_key
|
from app.strategy.clock import can_open_new, window_key
|
||||||
from app.strategy.exits import check_exits
|
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)
|
n2 = datetime(2026, 7, 24, 10, 0, tzinfo=_SH)
|
||||||
assert can_open_new(n2) is True
|
assert can_open_new(n2) is True
|
||||||
assert window_key(n2) == "20260724"
|
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;
|
leverage: number;
|
||||||
min_option_hours: number;
|
min_option_hours: number;
|
||||||
min_option_leverage: number;
|
min_option_leverage: number;
|
||||||
|
close_bid_mark_max_pct: number;
|
||||||
perp_qty_eth: number;
|
perp_qty_eth: number;
|
||||||
option_qty_eth: number;
|
option_qty_eth: number;
|
||||||
ledger: { equity: number; available: number };
|
ledger: { equity: number; available: number };
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default function SettingsPage() {
|
|||||||
const [leverage, setLeverage] = useState(3);
|
const [leverage, setLeverage] = useState(3);
|
||||||
const [minHours, setMinHours] = useState(12);
|
const [minHours, setMinHours] = useState(12);
|
||||||
const [minOptLev, setMinOptLev] = useState(100);
|
const [minOptLev, setMinOptLev] = useState(100);
|
||||||
|
const [closeDevPct, setCloseDevPct] = useState(30);
|
||||||
const [perpQty, setPerpQty] = useState(1);
|
const [perpQty, setPerpQty] = useState(1);
|
||||||
const [optQty, setOptQty] = useState(2);
|
const [optQty, setOptQty] = useState(2);
|
||||||
const [stratOk, setStratOk] = useState("");
|
const [stratOk, setStratOk] = useState("");
|
||||||
@@ -39,6 +40,7 @@ export default function SettingsPage() {
|
|||||||
setLeverage(s.leverage ?? 3);
|
setLeverage(s.leverage ?? 3);
|
||||||
setMinHours(s.min_option_hours ?? 12);
|
setMinHours(s.min_option_hours ?? 12);
|
||||||
setMinOptLev(s.min_option_leverage ?? 100);
|
setMinOptLev(s.min_option_leverage ?? 100);
|
||||||
|
setCloseDevPct(s.close_bid_mark_max_pct ?? 30);
|
||||||
setPerpQty(s.perp_qty_eth ?? 1);
|
setPerpQty(s.perp_qty_eth ?? 1);
|
||||||
setOptQty(s.option_qty_eth ?? 2);
|
setOptQty(s.option_qty_eth ?? 2);
|
||||||
})
|
})
|
||||||
@@ -90,6 +92,7 @@ export default function SettingsPage() {
|
|||||||
leverage,
|
leverage,
|
||||||
min_option_hours: minHours,
|
min_option_hours: minHours,
|
||||||
min_option_leverage: minOptLev,
|
min_option_leverage: minOptLev,
|
||||||
|
close_bid_mark_max_pct: closeDevPct,
|
||||||
perp_qty_eth: perpQty,
|
perp_qty_eth: perpQty,
|
||||||
option_qty_eth: optQty,
|
option_qty_eth: optQty,
|
||||||
}),
|
}),
|
||||||
@@ -179,6 +182,18 @@ export default function SettingsPage() {
|
|||||||
onChange={(e) => setExitPct(Number(e.target.value))}
|
onChange={(e) => setExitPct(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="field">
|
||||||
<label htmlFor="perp">永续 ETH 数量</label>
|
<label htmlFor="perp">永续 ETH 数量</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user