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:
@@ -233,6 +233,17 @@ async def put_strategy_settings(
|
|||||||
)
|
)
|
||||||
equity_to_apply = new_eq
|
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():
|
for k, v in data.items():
|
||||||
if k in KEYS:
|
if k in KEYS:
|
||||||
db.set_setting(k, str(v))
|
db.set_setting(k, str(v))
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ async def sim_open_group(
|
|||||||
db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",))
|
db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",))
|
||||||
)
|
)
|
||||||
gid = next_group_id(count)
|
gid = next_group_id(count)
|
||||||
|
engine = get_engine()
|
||||||
|
async with engine._lock:
|
||||||
|
if ex.has_open_position():
|
||||||
|
raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组")
|
||||||
r = ex.open_group(
|
r = ex.open_group(
|
||||||
group_id=gid,
|
group_id=gid,
|
||||||
bias=bias,
|
bias=bias,
|
||||||
@@ -151,12 +155,15 @@ async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict:
|
|||||||
)
|
)
|
||||||
from ..strategy import get_engine
|
from ..strategy import get_engine
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
# 与策略引擎共用锁,避免与自动平仓/开仓竞态
|
||||||
|
async with engine._lock:
|
||||||
r = get_executor().close_group(reason="manual")
|
r = get_executor().close_group(reason="manual")
|
||||||
if not r.ok and not r.liquidity_wait:
|
if not r.ok and not r.liquidity_wait:
|
||||||
raise HTTPException(status_code=400, detail=r.detail)
|
raise HTTPException(status_code=400, detail=r.detail)
|
||||||
if r.ok:
|
if r.ok:
|
||||||
# 与自动/紧急全平一致:成功全平后进入组间休息
|
# 与自动/紧急全平一致:成功全平后进入组间休息
|
||||||
get_engine().enter_rest_after_close()
|
engine.enter_rest_after_close()
|
||||||
try:
|
try:
|
||||||
from ..notify import wecom
|
from ..notify import wecom
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("binance live open option failed")
|
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)
|
release_open_slot_if_opening(self.db)
|
||||||
return OpenResult(ok=False, detail=f"币安开期权失败: {e}")
|
return OpenResult(ok=False, detail=f"币安开期权失败: {e}")
|
||||||
|
|
||||||
@@ -634,6 +640,8 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
detail=f"期权已平,永续待平(option_closed_perp_pending): {e}",
|
detail=f"期权已平,永续待平(option_closed_perp_pending): {e}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 期权已在 _mark_option_closed_perp_pending 入账/写 fill(含到期本地结算),
|
||||||
|
# 此处 pending_perp_only 必为 True;勿再按 is_expiry 二次入账。
|
||||||
return self._finalize_dual_close(
|
return self._finalize_dual_close(
|
||||||
pos=pos,
|
pos=pos,
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
@@ -647,14 +655,8 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
pf_px=pf_px,
|
pf_px=pf_px,
|
||||||
pf_fee=pf_fee,
|
pf_fee=pf_fee,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
option_fill_already_written=(
|
option_fill_already_written=bool(pending_perp_only),
|
||||||
st == "option_closed_perp_pending"
|
skip_option_cash=bool(pending_perp_only),
|
||||||
or (pending_perp_only and not is_expiry)
|
|
||||||
),
|
|
||||||
skip_option_cash=(
|
|
||||||
st == "option_closed_perp_pending"
|
|
||||||
or (pending_perp_only and not is_expiry)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mark_option_closed_perp_pending(
|
def _mark_option_closed_perp_pending(
|
||||||
@@ -950,6 +952,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
kind="close_perp",
|
kind="close_perp",
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
note=f"LIVE-BN close perp abandon option {reason}",
|
note=f"LIVE-BN close perp abandon option {reason}",
|
||||||
|
allow_negative=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
strike = self._group_strike(group_id, option_inst_id)
|
strike = self._group_strike(group_id, option_inst_id)
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ class OkxLiveExecutor(Matcher):
|
|||||||
).strip().lower()
|
).strip().lower()
|
||||||
return "isolated" if raw == "isolated" else "cross"
|
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:
|
def _guard_live(self) -> str | None:
|
||||||
ok, reason = live_ready()
|
ok, reason = live_ready()
|
||||||
if not ok:
|
if not ok:
|
||||||
@@ -130,6 +142,13 @@ class OkxLiveExecutor(Matcher):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("live open option failed")
|
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)
|
release_open_slot_if_opening(self.db)
|
||||||
return OpenResult(ok=False, detail=f"实盘开期权失败: {e}")
|
return OpenResult(ok=False, detail=f"实盘开期权失败: {e}")
|
||||||
|
|
||||||
@@ -141,6 +160,7 @@ class OkxLiveExecutor(Matcher):
|
|||||||
opt_qty = eth_from_contracts(opt_contracts, ct_mult)
|
opt_qty = eth_from_contracts(opt_contracts, ct_mult)
|
||||||
|
|
||||||
# 永续市价:按产品假设,失败原因实质为保证金不足 → 必须回滚期权
|
# 永续市价:按产品假设,失败原因实质为保证金不足 → 必须回滚期权
|
||||||
|
mgn = self._perp_margin_mode()
|
||||||
try:
|
try:
|
||||||
ct_val = client.get_ct_val(perp_inst, inst_type="SWAP")
|
ct_val = client.get_ct_val(perp_inst, inst_type="SWAP")
|
||||||
perp_sz = perp_close_contracts_okx(
|
perp_sz = perp_close_contracts_okx(
|
||||||
@@ -155,7 +175,6 @@ class OkxLiveExecutor(Matcher):
|
|||||||
else:
|
else:
|
||||||
side, pos_side = "sell", "short"
|
side, pos_side = "sell", "short"
|
||||||
leverage = self.ledger.get_setting_float("leverage", s.leverage)
|
leverage = self.ledger.get_setting_float("leverage", s.leverage)
|
||||||
mgn = self._perp_margin_mode()
|
|
||||||
try:
|
try:
|
||||||
client.set_leverage(
|
client.set_leverage(
|
||||||
perp_inst, leverage, mgn_mode=mgn, pos_side=pos_side
|
perp_inst, leverage, mgn_mode=mgn, pos_side=pos_side
|
||||||
@@ -239,8 +258,8 @@ class OkxLiveExecutor(Matcher):
|
|||||||
"""INSERT INTO groups(
|
"""INSERT INTO groups(
|
||||||
group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id,
|
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,
|
strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost,
|
||||||
exec_mode
|
exec_mode, perp_margin_mode
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(
|
(
|
||||||
group_id,
|
group_id,
|
||||||
"open",
|
"open",
|
||||||
@@ -257,6 +276,7 @@ class OkxLiveExecutor(Matcher):
|
|||||||
of_fee + pf_fee,
|
of_fee + pf_fee,
|
||||||
0.0,
|
0.0,
|
||||||
"LIVE",
|
"LIVE",
|
||||||
|
mgn,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.db._conn.execute(
|
self.db._conn.execute(
|
||||||
@@ -653,7 +673,7 @@ class OkxLiveExecutor(Matcher):
|
|||||||
inst_id=perp_inst,
|
inst_id=perp_inst,
|
||||||
side=side,
|
side=side,
|
||||||
sz=str(perp_sz),
|
sz=str(perp_sz),
|
||||||
td_mode=self._perp_margin_mode(),
|
td_mode=self._perp_margin_mode_for_group(group_id),
|
||||||
pos_side=pos_side,
|
pos_side=pos_side,
|
||||||
reduce_only=True,
|
reduce_only=True,
|
||||||
)
|
)
|
||||||
@@ -665,6 +685,8 @@ class OkxLiveExecutor(Matcher):
|
|||||||
detail=f"期权已平,永续待平(option_closed_perp_pending): {e}",
|
detail=f"期权已平,永续待平(option_closed_perp_pending): {e}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 期权已在 _mark_option_closed_perp_pending 入账/写 fill(含到期本地结算),
|
||||||
|
# 此处 pending_perp_only 必为 True;勿再按 is_expiry 二次入账。
|
||||||
return self._finalize_dual_close(
|
return self._finalize_dual_close(
|
||||||
pos=pos,
|
pos=pos,
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
@@ -678,14 +700,8 @@ class OkxLiveExecutor(Matcher):
|
|||||||
pf_px=pf_px,
|
pf_px=pf_px,
|
||||||
pf_fee=pf_fee,
|
pf_fee=pf_fee,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
option_fill_already_written=(
|
option_fill_already_written=bool(pending_perp_only),
|
||||||
st == "option_closed_perp_pending"
|
skip_option_cash=bool(pending_perp_only),
|
||||||
or (pending_perp_only and not is_expiry)
|
|
||||||
),
|
|
||||||
skip_option_cash=(
|
|
||||||
st == "option_closed_perp_pending"
|
|
||||||
or (pending_perp_only and not is_expiry)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mark_option_closed_perp_pending(
|
def _mark_option_closed_perp_pending(
|
||||||
@@ -938,7 +954,7 @@ class OkxLiveExecutor(Matcher):
|
|||||||
inst_id=perp_inst,
|
inst_id=perp_inst,
|
||||||
side=side,
|
side=side,
|
||||||
sz=str(perp_sz),
|
sz=str(perp_sz),
|
||||||
td_mode=self._perp_margin_mode(),
|
td_mode=self._perp_margin_mode_for_group(group_id),
|
||||||
pos_side=pos_side,
|
pos_side=pos_side,
|
||||||
reduce_only=True,
|
reduce_only=True,
|
||||||
)
|
)
|
||||||
@@ -957,6 +973,7 @@ class OkxLiveExecutor(Matcher):
|
|||||||
kind="close_perp",
|
kind="close_perp",
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
note=f"LIVE close perp abandon option {reason}",
|
note=f"LIVE close perp abandon option {reason}",
|
||||||
|
allow_negative=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 复用父类归档写入:临时改 fill 路径太重,直接调用父类会再平一次本地假价。
|
# 复用父类归档写入:临时改 fill 路径太重,直接调用父类会再平一次本地假价。
|
||||||
|
|||||||
@@ -157,7 +157,26 @@ class OkxTradeClient:
|
|||||||
fill = self._wait_fill(inst_id, ord_id)
|
fill = self._wait_fill(inst_id, ord_id)
|
||||||
return fill
|
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}"
|
path = f"/api/v5/trade/order?instId={inst_id}&ordId={ord_id}"
|
||||||
last: dict[str, Any] = {}
|
last: dict[str, Any] = {}
|
||||||
for _ in range(tries):
|
for _ in range(tries):
|
||||||
@@ -168,25 +187,21 @@ class OkxTradeClient:
|
|||||||
avg = safe_float(last.get("avgPx"))
|
avg = safe_float(last.get("avgPx"))
|
||||||
# 仅完全成交;部分成交继续等,避免账本张数与交易所不一致
|
# 仅完全成交;部分成交继续等,避免账本张数与交易所不一致
|
||||||
if state == "filled" and avg and avg > 0:
|
if state == "filled" and avg and avg > 0:
|
||||||
sz = safe_float(last.get("accFillSz")) or safe_float(last.get("sz")) or 0.0
|
return self._fill_from_order_row(inst_id, ord_id, last)
|
||||||
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,
|
|
||||||
)
|
|
||||||
if state in ("canceled", "failed"):
|
if state in ("canceled", "failed"):
|
||||||
raise RuntimeError(f"OKX 订单失败 state={state} {last}")
|
raise RuntimeError(f"OKX 订单失败 state={state} {last}")
|
||||||
time.sleep(0.3)
|
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}")
|
raise RuntimeError(f"OKX 订单未完全成交 ordId={ord_id} last={last}")
|
||||||
|
|
||||||
def sum_fill_fees(self, inst_id: str, ord_id: str) -> tuple[float, str]:
|
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
|
from .money import to_usdt
|
||||||
|
|
||||||
end = int(end_ms or int(time.time() * 1000))
|
end = int(end_ms or int(time.time() * 1000))
|
||||||
|
begin = int(begin_ms)
|
||||||
|
# OKX:after=更早时间戳边界,before=更晚;再本地按 uTime 过滤兜底
|
||||||
path = (
|
path = (
|
||||||
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}"
|
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}"
|
||||||
f"&before={end}&after={int(begin_ms)}"
|
f"&after={begin}&before={end}"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# positions-history 用 GET query;部分环境用 before/after 语义相反,失败则返回 None
|
rows = self._request("GET", path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("okx positions-history failed: %s", e)
|
||||||
|
try:
|
||||||
rows = self._request(
|
rows = self._request(
|
||||||
"GET",
|
"GET",
|
||||||
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}",
|
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e2:
|
||||||
logger.warning("okx positions-history failed: %s", e)
|
logger.warning("okx positions-history fallback failed: %s", e2)
|
||||||
return None
|
return None
|
||||||
total = 0.0
|
total = 0.0
|
||||||
hit = False
|
hit = False
|
||||||
for row in rows:
|
for row in rows:
|
||||||
u_time = int(safe_float(row.get("uTime")) or safe_float(row.get("cTime")) or 0)
|
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
|
continue
|
||||||
rpnl = safe_float(row.get("realizedPnl"))
|
rpnl = safe_float(row.get("realizedPnl"))
|
||||||
if rpnl is None:
|
if rpnl is None:
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ async def lifespan(app: FastAPI):
|
|||||||
# 而 health 显示 DB 里的 binance → 合约号/盘口错乱(期权一直 -/-)
|
# 而 health 显示 DB 里的 binance → 合约号/盘口错乱(期权一直 -/-)
|
||||||
from .exchange.runtime import load_runtime_settings
|
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()
|
settings = load_runtime_settings()
|
||||||
engine = StrategyEngine()
|
engine = StrategyEngine()
|
||||||
set_engine(engine)
|
set_engine(engine)
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ CREATE TABLE IF NOT EXISTS groups (
|
|||||||
note TEXT,
|
note TEXT,
|
||||||
exec_mode TEXT,
|
exec_mode TEXT,
|
||||||
funding_usdt REAL,
|
funding_usdt REAL,
|
||||||
settle_index_px REAL
|
settle_index_px REAL,
|
||||||
|
perp_margin_mode TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fills (
|
CREATE TABLE IF NOT EXISTS fills (
|
||||||
@@ -164,6 +165,7 @@ class Database:
|
|||||||
("groups", "exec_mode", "TEXT"),
|
("groups", "exec_mode", "TEXT"),
|
||||||
("groups", "funding_usdt", "REAL"),
|
("groups", "funding_usdt", "REAL"),
|
||||||
("groups", "settle_index_px", "REAL"),
|
("groups", "settle_index_px", "REAL"),
|
||||||
|
("groups", "perp_margin_mode", "TEXT"),
|
||||||
("fills", "exec_mode", "TEXT"),
|
("fills", "exec_mode", "TEXT"),
|
||||||
("fills", "fee_ccy", "TEXT"),
|
("fills", "fee_ccy", "TEXT"),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ def _live_balances() -> dict[str, float | None]:
|
|||||||
"trading_usdc": None,
|
"trading_usdc": None,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
from ..exchange.runtime import load_runtime_settings
|
||||||
|
|
||||||
|
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
|
from ..live.okx_funds import OkxFundsClient
|
||||||
|
|
||||||
client = OkxFundsClient()
|
client = OkxFundsClient()
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 审计说明 — 开/平仓逻辑与实盘安全(2026-07-29)
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- 开仓 / 平仓 / 到期双腿平仓 / 弃期权只平永续
|
||||||
|
- LIVE 执行器(OKX / 币安)本地账本与交易所一致性
|
||||||
|
- 手动开平仓 API 与策略引擎并发
|
||||||
|
- 鉴权密钥、资金可开判定、平仓盈亏查询
|
||||||
|
|
||||||
|
## 结论摘要
|
||||||
|
|
||||||
|
| 严重度 | 问题 | 处置 |
|
||||||
|
|--------|------|------|
|
||||||
|
| Critical | 到期双平:`_mark_option_closed_perp_pending` 后仍因 `not is_expiry` 二次入账期权现金/fill | **已修**:`skip_option_cash` / `option_fill_already_written` = `pending_perp_only` |
|
||||||
|
| Critical | 弃期权路径:交易所已平永续,`apply_cash` 无 `allow_negative` → 本地可拒记卡仓 | **已修**:OKX/币安 abandon 均 `allow_negative=True` |
|
||||||
|
| High | 平仓用「当前」永续保证金模式,持仓中可改设置导致模式错配 | **已修**:开仓写入 `groups.perp_margin_mode`;平仓读组记录;持仓中禁止改设置 |
|
||||||
|
| High | 手动 `POST /close-group`(及手动开仓)未占引擎锁,可与自动 tick 竞态 | **已修**:`async with engine._lock` |
|
||||||
|
| High | 期权成交等待超时 → 释放 opening,可能重复开 | **已修**:等成交通道加长+超时有成交则回填;含 `ordId` 失败保留 opening |
|
||||||
|
| Med | `get_closed_perp_pnl_usdt` 构造了时间过滤 URL 却请求未过滤路径 | **已修**:优先带 after/before 查询,失败再兜底 |
|
||||||
|
| Med | LIVE 默认可使用 `change-me-…` 鉴权密钥 | **已修**:LIVE 启动拒绝默认 `AUTH_SECRET` |
|
||||||
|
| Med | 币安 LIVE 可开资金仍走 OKX 客户端 | **已修**:币安不再误调 OKX,余额视为未知 |
|
||||||
|
|
||||||
|
## 开仓路径(自检)
|
||||||
|
|
||||||
|
1. `claim_open_slot` → `opening` 占槽,防并发双开。
|
||||||
|
2. 先期权后永续;永续失败则卖回期权,卖回失败 → `half_open`。
|
||||||
|
3. 期权下单已返回 `ordId` 但查单未确认:不释放 `opening`,禁止再开,须核对交易所。
|
||||||
|
4. LIVE 成交后本地账本一律 `allow_negative`,避免「交易所有仓、本地拒记」。
|
||||||
|
|
||||||
|
## 平仓路径(自检)
|
||||||
|
|
||||||
|
1. 先平期权并 `_mark_option_closed_perp_pending`(写 fill + 入账),再平永续。
|
||||||
|
2. 永续失败时状态已是 `option_closed_perp_pending`,重试只平永续,不再卖期权。
|
||||||
|
3. `_finalize_dual_close` 在 `pending_perp_only` 时跳过期权二次入账(**含 expiry**)。
|
||||||
|
4. 弃期权:交易所平永续后本地必须入账成功(`allow_negative`),再归档 residual。
|
||||||
|
5. OKX 平永续 `tdMode` 使用开仓时组上记录的 `perp_margin_mode`。
|
||||||
|
|
||||||
|
## 安全
|
||||||
|
|
||||||
|
- Web 登录依赖 `AUTH_SECRET`;LIVE 禁止默认密钥启动。
|
||||||
|
- 策略/资金 API 均需登录;手动交易另需开关。
|
||||||
|
- 不在本文档记录任何真实密钥或口令。
|
||||||
|
|
||||||
|
## 残留 / 后续
|
||||||
|
|
||||||
|
- 币安 LIVE 交易账户余额接入后,再恢复精确「可开」判定。
|
||||||
|
- `opening` 残留需人工或后续对账任务清槽(现策略:宁卡不开重复仓)。
|
||||||
|
- 期权腿交易所对账仍弱于永续(启动 reconcile 主要看永续)。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `backend/app/live/executor.py` / `binance_executor.py`
|
||||||
|
- `backend/app/live/okx_trade.py`
|
||||||
|
- `backend/app/api/sim.py` / `settings.py`
|
||||||
|
- `backend/app/main.py`
|
||||||
|
- `backend/app/models/db.py`
|
||||||
|
- `backend/app/strategy/open_capacity.py`
|
||||||
@@ -5,6 +5,23 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-29 — 开平仓/实盘安全审计修复
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
1. **Critical**:到期双平不再二次入账期权(`skip_option_cash` 跟 `pending_perp_only`)。
|
||||||
|
2. **Critical**:弃期权平永续后本地账本强制 `allow_negative`,避免交易所已平、本地卡仓。
|
||||||
|
3. 开仓写入 `groups.perp_margin_mode`;平仓用开仓时模式;持仓中禁止改保证金模式。
|
||||||
|
4. 手动开/平仓走策略引擎锁,避免与自动 tick 竞态。
|
||||||
|
5. OKX 等成交加长并超时有成交则回填;含 `ordId` 的未确认失败保留 `opening`。
|
||||||
|
6. 修复 positions-history 盈亏查询未带时间过滤;LIVE 拒绝默认 `AUTH_SECRET`;币安可开不再误调 OKX 资金。
|
||||||
|
|
||||||
|
### 审计
|
||||||
|
|
||||||
|
详见 [`docs/审计说明-2026-07-29-开平仓与实盘安全.md`](./审计说明-2026-07-29-开平仓与实盘安全.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-07-29 — 实盘资金读交易所;永续全仓/逐仓
|
## 2026-07-29 — 实盘资金读交易所;永续全仓/逐仓
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
Reference in New Issue
Block a user