Fix LIVE open/close double-book and security audit findings.

Prevent expiry dual-close from re-booking option cash, abandon ledger rejection, margin-mode mismatch, and manual close races; harden fill wait and refuse default AUTH_SECRET on LIVE.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-29 20:11:29 +08:00
parent c6e8f6fe1e
commit ec87cf2104
10 changed files with 229 additions and 80 deletions
+11
View File
@@ -233,6 +233,17 @@ async def put_strategy_settings(
)
equity_to_apply = new_eq
if "perp_margin_mode" in data:
new_mm = str(data["perp_margin_mode"]).strip().lower()
old_mm = str(
db.get_setting("perp_margin_mode", s.perp_margin_mode) or s.perp_margin_mode
).strip().lower()
if new_mm != old_mm and Matcher(db).has_open_position():
raise HTTPException(
status_code=409,
detail="有未平仓,无法切换永续保证金模式;请先平仓后再改",
)
for k, v in data.items():
if k in KEYS:
db.set_setting(k, str(v))
+32 -25
View File
@@ -103,16 +103,20 @@ async def sim_open_group(
db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",))
)
gid = next_group_id(count)
r = ex.open_group(
group_id=gid,
bias=bias,
option_side=option_side,
perp_side=perp_side,
option_inst_id=option_inst,
entry_index_px=float(pick.underlying_px),
strike=pick.pair.strike,
expiry_ymd=pick.pair.expiry_ymd,
)
engine = get_engine()
async with engine._lock:
if ex.has_open_position():
raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组")
r = ex.open_group(
group_id=gid,
bias=bias,
option_side=option_side,
perp_side=perp_side,
option_inst_id=option_inst,
entry_index_px=float(pick.underlying_px),
strike=pick.pair.strike,
expiry_ymd=pick.pair.expiry_ymd,
)
if not r.ok:
raise HTTPException(status_code=400, detail=r.detail)
try:
@@ -151,22 +155,25 @@ async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict:
)
from ..strategy import get_engine
r = get_executor().close_group(reason="manual")
if not r.ok and not r.liquidity_wait:
raise HTTPException(status_code=400, detail=r.detail)
if r.ok:
# 与自动/紧急全平一致:成功全平后进入组间休息
get_engine().enter_rest_after_close()
try:
from ..notify import wecom
engine = get_engine()
# 与策略引擎共用锁,避免与自动平仓/开仓竞态
async with engine._lock:
r = get_executor().close_group(reason="manual")
if not r.ok and not r.liquidity_wait:
raise HTTPException(status_code=400, detail=r.detail)
if r.ok:
# 与自动/紧急全平一致:成功全平后进入组间休息
engine.enter_rest_after_close()
try:
from ..notify import wecom
wecom.notify_close(
reason="manual",
detail=r.detail,
data=r.data or {},
)
except Exception:
pass
wecom.notify_close(
reason="manual",
detail=r.detail,
data=r.data or {},
)
except Exception:
pass
return {
"ok": r.ok,
"liquidity_wait": r.liquidity_wait,
+11 -8
View File
@@ -114,6 +114,12 @@ class BinanceLiveExecutor(Matcher):
)
except Exception as e:
logger.exception("binance live open option failed")
msg = str(e)
if "orderId=" in msg or "orderId" in msg.lower():
return OpenResult(
ok=False,
detail=f"币安开期权未确认成交(保留 opening 防重复开,请核对交易所): {e}",
)
release_open_slot_if_opening(self.db)
return OpenResult(ok=False, detail=f"币安开期权失败: {e}")
@@ -634,6 +640,8 @@ class BinanceLiveExecutor(Matcher):
detail=f"期权已平,永续待平(option_closed_perp_pending): {e}",
)
# 期权已在 _mark_option_closed_perp_pending 入账/写 fill(含到期本地结算),
# 此处 pending_perp_only 必为 True;勿再按 is_expiry 二次入账。
return self._finalize_dual_close(
pos=pos,
group_id=group_id,
@@ -647,14 +655,8 @@ class BinanceLiveExecutor(Matcher):
pf_px=pf_px,
pf_fee=pf_fee,
reason=reason,
option_fill_already_written=(
st == "option_closed_perp_pending"
or (pending_perp_only and not is_expiry)
),
skip_option_cash=(
st == "option_closed_perp_pending"
or (pending_perp_only and not is_expiry)
),
option_fill_already_written=bool(pending_perp_only),
skip_option_cash=bool(pending_perp_only),
)
def _mark_option_closed_perp_pending(
@@ -950,6 +952,7 @@ class BinanceLiveExecutor(Matcher):
kind="close_perp",
group_id=group_id,
note=f"LIVE-BN close perp abandon option {reason}",
allow_negative=True,
)
strike = self._group_strike(group_id, option_inst_id)
+30 -13
View File
@@ -47,6 +47,18 @@ class OkxLiveExecutor(Matcher):
).strip().lower()
return "isolated" if raw == "isolated" else "cross"
def _perp_margin_mode_for_group(self, group_id: str | None) -> str:
"""平仓用开仓时写入的保证金模式;缺省回退当前设置。"""
if group_id:
g = self.db.fetchone(
"SELECT perp_margin_mode FROM groups WHERE group_id=?", (group_id,)
)
if g is not None:
m = str(g["perp_margin_mode"] or "").strip().lower()
if m in ("cross", "isolated"):
return m
return self._perp_margin_mode()
def _guard_live(self) -> str | None:
ok, reason = live_ready()
if not ok:
@@ -130,6 +142,13 @@ class OkxLiveExecutor(Matcher):
)
except Exception as e:
logger.exception("live open option failed")
msg = str(e)
# 已拿到 ordId:可能已成交,禁止释放 opening 以免重复开仓
if "ordId=" in msg:
return OpenResult(
ok=False,
detail=f"实盘开期权未确认成交(保留 opening 防重复开,请核对交易所): {e}",
)
release_open_slot_if_opening(self.db)
return OpenResult(ok=False, detail=f"实盘开期权失败: {e}")
@@ -141,6 +160,7 @@ class OkxLiveExecutor(Matcher):
opt_qty = eth_from_contracts(opt_contracts, ct_mult)
# 永续市价:按产品假设,失败原因实质为保证金不足 → 必须回滚期权
mgn = self._perp_margin_mode()
try:
ct_val = client.get_ct_val(perp_inst, inst_type="SWAP")
perp_sz = perp_close_contracts_okx(
@@ -155,7 +175,6 @@ class OkxLiveExecutor(Matcher):
else:
side, pos_side = "sell", "short"
leverage = self.ledger.get_setting_float("leverage", s.leverage)
mgn = self._perp_margin_mode()
try:
client.set_leverage(
perp_inst, leverage, mgn_mode=mgn, pos_side=pos_side
@@ -239,8 +258,8 @@ class OkxLiveExecutor(Matcher):
"""INSERT INTO groups(
group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id,
strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost,
exec_mode
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
exec_mode, perp_margin_mode
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"open",
@@ -257,6 +276,7 @@ class OkxLiveExecutor(Matcher):
of_fee + pf_fee,
0.0,
"LIVE",
mgn,
),
)
self.db._conn.execute(
@@ -653,7 +673,7 @@ class OkxLiveExecutor(Matcher):
inst_id=perp_inst,
side=side,
sz=str(perp_sz),
td_mode=self._perp_margin_mode(),
td_mode=self._perp_margin_mode_for_group(group_id),
pos_side=pos_side,
reduce_only=True,
)
@@ -665,6 +685,8 @@ class OkxLiveExecutor(Matcher):
detail=f"期权已平,永续待平(option_closed_perp_pending): {e}",
)
# 期权已在 _mark_option_closed_perp_pending 入账/写 fill(含到期本地结算),
# 此处 pending_perp_only 必为 True;勿再按 is_expiry 二次入账。
return self._finalize_dual_close(
pos=pos,
group_id=group_id,
@@ -678,14 +700,8 @@ class OkxLiveExecutor(Matcher):
pf_px=pf_px,
pf_fee=pf_fee,
reason=reason,
option_fill_already_written=(
st == "option_closed_perp_pending"
or (pending_perp_only and not is_expiry)
),
skip_option_cash=(
st == "option_closed_perp_pending"
or (pending_perp_only and not is_expiry)
),
option_fill_already_written=bool(pending_perp_only),
skip_option_cash=bool(pending_perp_only),
)
def _mark_option_closed_perp_pending(
@@ -938,7 +954,7 @@ class OkxLiveExecutor(Matcher):
inst_id=perp_inst,
side=side,
sz=str(perp_sz),
td_mode=self._perp_margin_mode(),
td_mode=self._perp_margin_mode_for_group(group_id),
pos_side=pos_side,
reduce_only=True,
)
@@ -957,6 +973,7 @@ class OkxLiveExecutor(Matcher):
kind="close_perp",
group_id=group_id,
note=f"LIVE close perp abandon option {reason}",
allow_negative=True,
)
# 复用父类归档写入:临时改 fill 路径太重,直接调用父类会再平一次本地假价。
+45 -25
View File
@@ -157,7 +157,26 @@ class OkxTradeClient:
fill = self._wait_fill(inst_id, ord_id)
return fill
def _wait_fill(self, inst_id: str, ord_id: str, *, tries: int = 20) -> LiveFill:
def _fill_from_order_row(self, inst_id: str, ord_id: str, row: dict[str, Any]) -> LiveFill:
avg = safe_float(row.get("avgPx")) or 0.0
sz = safe_float(row.get("accFillSz")) or safe_float(row.get("sz")) or 0.0
fee = abs(safe_float(row.get("fee")) or 0.0)
fee_ccy = str(row.get("feeCcy") or "USDT")
if fee <= 0 and ord_id:
fee, fee_ccy = self.sum_fill_fees(inst_id, ord_id)
from .money import abs_fee_usdt
return LiveFill(
inst_id=inst_id,
side=str(row.get("side") or ""),
avg_px=float(avg),
sz=float(sz),
fee=abs_fee_usdt(fee, fee_ccy),
ord_id=ord_id,
raw=row,
)
def _wait_fill(self, inst_id: str, ord_id: str, *, tries: int = 40) -> LiveFill:
path = f"/api/v5/trade/order?instId={inst_id}&ordId={ord_id}"
last: dict[str, Any] = {}
for _ in range(tries):
@@ -168,25 +187,21 @@ class OkxTradeClient:
avg = safe_float(last.get("avgPx"))
# 仅完全成交;部分成交继续等,避免账本张数与交易所不一致
if state == "filled" and avg and avg > 0:
sz = safe_float(last.get("accFillSz")) or safe_float(last.get("sz")) or 0.0
fee = abs(safe_float(last.get("fee")) or 0.0)
fee_ccy = str(last.get("feeCcy") or "USDT")
if fee <= 0 and ord_id:
fee, fee_ccy = self.sum_fill_fees(inst_id, ord_id)
from .money import abs_fee_usdt
return LiveFill(
inst_id=inst_id,
side=str(last.get("side") or ""),
avg_px=float(avg),
sz=float(sz),
fee=abs_fee_usdt(fee, fee_ccy),
ord_id=ord_id,
raw=last,
)
return self._fill_from_order_row(inst_id, ord_id, last)
if state in ("canceled", "failed"):
raise RuntimeError(f"OKX 订单失败 state={state} {last}")
time.sleep(0.3)
# 超时兜底:已 filled 或已有成交量+均价则回填,避免「有仓无账」
state = str(last.get("state") or "")
avg = safe_float(last.get("avgPx"))
acc = safe_float(last.get("accFillSz")) or 0.0
if state == "filled" or (acc > 0 and avg and avg > 0):
logger.warning(
"OKX fill wait timeout but using last fill data ordId=%s state=%s",
ord_id,
state,
)
return self._fill_from_order_row(inst_id, ord_id, last)
raise RuntimeError(f"OKX 订单未完全成交 ordId={ord_id} last={last}")
def sum_fill_fees(self, inst_id: str, ord_id: str) -> tuple[float, str]:
@@ -305,24 +320,29 @@ class OkxTradeClient:
from .money import to_usdt
end = int(end_ms or int(time.time() * 1000))
begin = int(begin_ms)
# OKXafter=更早时间戳边界,before=更晚;再本地按 uTime 过滤兜底
path = (
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}"
f"&before={end}&after={int(begin_ms)}"
f"&after={begin}&before={end}"
)
try:
# positions-history 用 GET query;部分环境用 before/after 语义相反,失败则返回 None
rows = self._request(
"GET",
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}",
)
rows = self._request("GET", path)
except Exception as e:
logger.warning("okx positions-history failed: %s", e)
return None
try:
rows = self._request(
"GET",
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}",
)
except Exception as e2:
logger.warning("okx positions-history fallback failed: %s", e2)
return None
total = 0.0
hit = False
for row in rows:
u_time = int(safe_float(row.get("uTime")) or safe_float(row.get("cTime")) or 0)
if u_time and (u_time < int(begin_ms) - 60_000 or u_time > end + 60_000):
if u_time and (u_time < begin - 60_000 or u_time > end + 60_000):
continue
rpnl = safe_float(row.get("realizedPnl"))
if rpnl is None:
+8
View File
@@ -36,6 +36,14 @@ async def lifespan(app: FastAPI):
# 而 health 显示 DB 里的 binance → 合约号/盘口错乱(期权一直 -/-)
from .exchange.runtime import load_runtime_settings
app_settings = get_settings()
if not app_settings.is_sim:
secret = (app_settings.auth_secret or "").strip()
if not secret or secret == "change-me-eth-hedge-sim-secret":
raise RuntimeError(
"LIVE 拒绝启动:请在 .env 设置非默认 AUTH_SECRET(勿用 change-me-eth-hedge-sim-secret"
)
settings = load_runtime_settings()
engine = StrategyEngine()
set_engine(engine)
+3 -1
View File
@@ -43,7 +43,8 @@ CREATE TABLE IF NOT EXISTS groups (
note TEXT,
exec_mode TEXT,
funding_usdt REAL,
settle_index_px REAL
settle_index_px REAL,
perp_margin_mode TEXT
);
CREATE TABLE IF NOT EXISTS fills (
@@ -164,6 +165,7 @@ class Database:
("groups", "exec_mode", "TEXT"),
("groups", "funding_usdt", "REAL"),
("groups", "settle_index_px", "REAL"),
("groups", "perp_margin_mode", "TEXT"),
("fills", "exec_mode", "TEXT"),
("fills", "fee_ccy", "TEXT"),
):
+15 -8
View File
@@ -60,15 +60,22 @@ def _live_balances() -> dict[str, float | None]:
"trading_usdc": None,
}
try:
from ..live.okx_funds import OkxFundsClient
from ..exchange.runtime import load_runtime_settings
client = OkxFundsClient()
try:
bal = client.fetch_balances()
out["trading_usdt"] = _f(bal.get("trading_usdt"))
out["trading_usdc"] = _f(bal.get("trading_usdc"))
finally:
client.close()
ex = str(load_runtime_settings().exchange or "").strip().lower()
if ex in ("binance", "bn"):
# 币安资金接口尚未接入;返回 None 使可开判定为「未知」而非误用 OKX
logger.debug("open_capacity: binance live balance not wired; treating as unknown")
else:
from ..live.okx_funds import OkxFundsClient
client = OkxFundsClient()
try:
bal = client.fetch_balances()
out["trading_usdt"] = _f(bal.get("trading_usdt"))
out["trading_usdc"] = _f(bal.get("trading_usdc"))
finally:
client.close()
except Exception as e:
logger.warning("open_capacity live balance failed: %s", e)
_live_bal_cache["ts"] = now