Audit fixes: LIVE symbols/fills/expiry/pending, security harden, add 更新说明.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-26 22:28:03 +08:00
parent bc8d1fb127
commit f48ea5bbcc
18 changed files with 1389 additions and 1069 deletions
+97 -36
View File
@@ -7,11 +7,12 @@ import time
from ..config import get_settings
from ..env_store import live_ready
from ..sim.liquidity import contracts_for_eth
from ..sim.liquidity import contracts_for_eth, eth_from_contracts
from ..sim.matcher import CloseResult, Matcher, OpenResult
from ..sim.pricing import option_expiry_settle, option_intrinsic
from ..strategy.session import get_session
from .binance_trade import BinanceTradeClient
from .symbols import live_settings, resolve_perp_inst_id
logger = logging.getLogger(__name__)
@@ -38,9 +39,11 @@ class BinanceLiveExecutor(Matcher):
return base
from .live_pnl import enrich_live_unrealized
s = get_settings()
gid = base.get("group_id")
open_at = None
perp_inst = resolve_perp_inst_id(
self.db, group_id=str(gid) if gid else None
)
if gid:
g = self.db.fetchone(
"SELECT open_at_ms, perp_inst_id FROM groups WHERE group_id=?",
@@ -48,11 +51,8 @@ class BinanceLiveExecutor(Matcher):
)
if g:
open_at = int(g["open_at_ms"] or 0) or None
perp_inst = str(g["perp_inst_id"] or s.perp_inst_id)
else:
perp_inst = s.perp_inst_id
else:
perp_inst = s.perp_inst_id
if g["perp_inst_id"]:
perp_inst = str(g["perp_inst_id"])
try:
client = self._client()
except Exception:
@@ -83,7 +83,7 @@ class BinanceLiveExecutor(Matcher):
if err:
return OpenResult(ok=False, detail=err)
s = get_settings()
s = live_settings()
if self.has_open_position():
st = self.position_status()
return OpenResult(
@@ -92,6 +92,7 @@ class BinanceLiveExecutor(Matcher):
)
client = self._client()
perp_inst = resolve_perp_inst_id(self.db)
perp_qty = self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth)
opt_qty = self.ledger.get_setting_float("option_qty_eth", s.option_qty_eth)
ct_mult = self._ct_mult(option_inst_id)
@@ -107,6 +108,12 @@ class BinanceLiveExecutor(Matcher):
logger.exception("binance live open option failed")
return OpenResult(ok=False, detail=f"币安开期权失败: {e}")
filled_opt_contracts = float(opt_fill.sz) if opt_fill.sz and opt_fill.sz > 0 else float(
int(round(opt_contracts))
)
opt_contracts = filled_opt_contracts
opt_qty = eth_from_contracts(opt_contracts, ct_mult)
# 永续市价失败(多为保证金不足)→ 必须回滚期权
try:
if perp_side == "long":
@@ -114,7 +121,7 @@ class BinanceLiveExecutor(Matcher):
else:
side, pos_side = "SELL", "SHORT"
perp_fill_live = client.place_perp_market(
symbol=s.perp_inst_id,
symbol=perp_inst,
side=side,
qty_eth=perp_qty,
position_side=pos_side,
@@ -159,6 +166,12 @@ class BinanceLiveExecutor(Matcher):
pf_px = float(perp_fill_live.avg_px)
of_fee = float(opt_fill.fee)
pf_fee = float(perp_fill_live.fee)
filled_perp_qty = (
float(perp_fill_live.sz)
if perp_fill_live.sz and perp_fill_live.sz > 0
else perp_qty
)
perp_qty = filled_perp_qty
initial_premium = of_px * opt_qty
of_notional = of_px * opt_qty
pf_notional = pf_px * perp_qty
@@ -194,7 +207,7 @@ class BinanceLiveExecutor(Matcher):
option_side,
perp_side,
option_inst_id,
s.perp_inst_id,
perp_inst,
strike,
expiry_ymd,
entry_index_px,
@@ -235,7 +248,7 @@ class BinanceLiveExecutor(Matcher):
"perp",
"open",
perp_side,
s.perp_inst_id,
perp_inst,
perp_qty,
None,
pf_px,
@@ -303,7 +316,7 @@ class BinanceLiveExecutor(Matcher):
detail: str,
) -> None:
"""期权已成交、永续未开且回滚失败 → 落 half_open,禁止新开,待 repair。"""
s = get_settings()
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
initial_premium = of_px * opt_qty
self.ledger.apply_cash(
-(of_px * opt_qty + of_fee),
@@ -331,7 +344,7 @@ class BinanceLiveExecutor(Matcher):
option_side,
perp_side,
option_inst_id,
s.perp_inst_id,
perp_inst,
strike,
expiry_ymd,
entry_index_px,
@@ -475,7 +488,7 @@ class BinanceLiveExecutor(Matcher):
if err:
return CloseResult(ok=False, detail=err)
s = get_settings()
s = live_settings()
pos = self.current_position()
st = str(pos.get("status") or "")
if st == "half_open":
@@ -490,6 +503,7 @@ class BinanceLiveExecutor(Matcher):
opt_qty = float(pos["option_qty_eth"])
perp_qty = float(pos["perp_qty_eth"])
opt_contracts = float(pos["option_qty_contracts"] or 0)
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
client = self._client()
is_expiry = reason == "expiry"
fee_rate = self._fee_rate()
@@ -527,34 +541,45 @@ class BinanceLiveExecutor(Matcher):
of_fee = float(prev["fee"] or 0)
of_notional = float(prev["notional"] or (of_px * opt_qty))
of_slip = float(prev["slip"] or 0)
elif is_expiry:
if intrinsic is None:
return CloseResult(ok=False, detail="到期结算失败:缺行权价或标的价")
of = option_expiry_settle(
intrinsic=float(intrinsic), qty_eth=opt_qty, fee_rate=fee_rate
)
of_px, of_fee, of_slip, of_notional = of.fill_px, of.fee, of.slip, of.notional
else:
# 含到期:优先交易所真实平期权;失败且无内在价值时可本地结算
try:
opt_live = client.place_option_market(
symbol=option_inst_id,
side="SELL",
quantity=opt_contracts,
quantity=max(1.0, opt_contracts),
reduce_only=True,
)
of_px = float(opt_live.avg_px)
of_fee = float(opt_live.fee)
filled_c = float(opt_live.sz) if opt_live.sz and opt_live.sz > 0 else opt_contracts
opt_contracts = filled_c
opt_qty = eth_from_contracts(opt_contracts, self._ct_mult(option_inst_id))
of_notional = of_px * opt_qty
except Exception as e:
if not bypass_liquidity:
if is_expiry and intrinsic is not None:
of = option_expiry_settle(
intrinsic=float(intrinsic), qty_eth=opt_qty, fee_rate=fee_rate
)
of_px, of_fee, of_slip, of_notional = (
of.fill_px,
of.fee,
of.slip,
of.notional,
)
logger.warning(
"expiry option exchange close failed, local settle: %s", e
)
elif not bypass_liquidity:
return CloseResult(
ok=False,
detail=f"币安平期权失败: {e}",
liquidity_wait=True,
)
return CloseResult(ok=False, detail=f"币安平期权失败: {e}")
else:
return CloseResult(ok=False, detail=f"币安平期权失败: {e}")
# 期权已平:立刻落 pending,避免永续失败后重试再卖期权
# 期权已平(或到期本地结算):立刻落 pending,避免永续失败后重试再卖期权
self._mark_option_closed_perp_pending(
group_id=group_id,
option_inst_id=option_inst_id,
@@ -574,7 +599,7 @@ class BinanceLiveExecutor(Matcher):
else:
side, pos_side = "BUY", "SHORT"
perp_live = client.place_perp_market(
symbol=s.perp_inst_id,
symbol=perp_inst,
side=side,
qty_eth=perp_qty,
position_side=pos_side,
@@ -681,7 +706,8 @@ class BinanceLiveExecutor(Matcher):
option_fill_already_written: bool,
skip_option_cash: bool,
) -> CloseResult:
s = get_settings()
s = live_settings()
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
perp_side = str(pos["perp_side"])
perp_qty = float(pos["perp_qty_eth"])
opt_entry = float(pos["option_entry_px"])
@@ -749,7 +775,7 @@ class BinanceLiveExecutor(Matcher):
"perp",
"close",
"flat",
s.perp_inst_id,
perp_inst,
perp_qty,
None,
pf_px,
@@ -793,7 +819,7 @@ class BinanceLiveExecutor(Matcher):
client=self._client(),
exchange="binance",
group_id=group_id,
perp_inst_id=str((g2["perp_inst_id"] if g2 else None) or s.perp_inst_id),
perp_inst_id=str((g2["perp_inst_id"] if g2 else None) or resolve_perp_inst_id(self.db, group_id=group_id)),
open_at_ms=int(g2["open_at_ms"]) if g2 and g2["open_at_ms"] else None,
local_net=float(net) if net is not None else None,
)
@@ -816,10 +842,7 @@ class BinanceLiveExecutor(Matcher):
err = self._guard_live()
if err:
return CloseResult(ok=False, detail=err)
if require_deep_otm and not self.option_is_deep_otm():
return CloseResult(ok=False, detail="期权非远虚,应走双腿全平")
s = get_settings()
pos = self.current_position()
st = str(pos.get("status") or "")
if st not in ("open", "option_closed_perp_pending") or not pos.get("group_id"):
@@ -829,17 +852,57 @@ class BinanceLiveExecutor(Matcher):
return self.close_group(reason=reason, bypass_liquidity=True)
group_id = str(pos["group_id"])
option_inst_id = str(pos["option_inst_id"])
option_side = str(pos["option_side"])
opt_contracts = float(pos["option_qty_contracts"] or 0)
opt_qty = float(pos["option_qty_eth"])
perp_side = str(pos["perp_side"])
perp_qty = float(pos["perp_qty_eth"])
perp_entry = float(pos["perp_entry_px"])
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
client = self._client()
# 优先尝试交易所平期权;成功则走双腿全平
try:
opt_live = client.place_option_market(
symbol=option_inst_id,
side="SELL",
quantity=max(1.0, opt_contracts),
reduce_only=True,
)
of_px = float(opt_live.avg_px)
of_fee = float(opt_live.fee)
filled_c = float(opt_live.sz) if opt_live.sz and opt_live.sz > 0 else opt_contracts
opt_contracts = filled_c
opt_qty = eth_from_contracts(opt_contracts, self._ct_mult(option_inst_id))
of_notional = of_px * opt_qty
self._mark_option_closed_perp_pending(
group_id=group_id,
option_inst_id=option_inst_id,
opt_qty=opt_qty,
opt_contracts=opt_contracts,
of_px=of_px,
of_fee=of_fee,
of_notional=of_notional,
of_slip=0.0,
reason=reason,
)
return self.close_group(reason=reason, bypass_liquidity=True)
except Exception as e:
logger.warning("abandon: option exchange sell failed: %s", e)
if require_deep_otm and not self.option_is_deep_otm():
return CloseResult(
ok=False,
detail=f"期权平单失败且非远虚,应走双腿全平: {e}",
)
try:
if perp_side == "long":
side, pos_side = "SELL", "LONG"
else:
side, pos_side = "BUY", "SHORT"
perp_live = client.place_perp_market(
symbol=s.perp_inst_id,
symbol=perp_inst,
side=side,
qty_eth=perp_qty,
position_side=pos_side,
@@ -862,8 +925,6 @@ class BinanceLiveExecutor(Matcher):
note=f"LIVE-BN close perp abandon option {reason}",
)
option_inst_id = str(pos["option_inst_id"])
option_side = str(pos["option_side"])
strike = self._group_strike(group_id, option_inst_id)
g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
expiry_ymd = str(g["expiry_ymd"]) if g and g["expiry_ymd"] else None
@@ -893,7 +954,7 @@ class BinanceLiveExecutor(Matcher):
"perp",
"close",
"flat",
s.perp_inst_id,
perp_inst,
perp_qty,
None,
pf_px,
+6 -1
View File
@@ -219,12 +219,17 @@ class BinanceTradeClient:
sz = safe_float(q.get("executedQty")) or safe_float(q.get("quantity"))
st = str(q.get("status") or "").upper()
data = q
if avg and avg > 0 and st in ("FILLED", "PARTIALLY_FILLED"):
if avg and avg > 0 and st == "FILLED":
break
if st in ("CANCELED", "REJECTED", "EXPIRED"):
raise RuntimeError(f"币安期权订单失败 status={st} {q}")
if st == "PARTIALLY_FILLED":
continue
if not avg or avg <= 0:
raise RuntimeError(f"币安期权无成交均价: {data}")
st_final = str(data.get("status") or "").upper()
if st_final and st_final != "FILLED":
raise RuntimeError(f"币安期权未完全成交 status={st_final} {data}")
from .money import abs_fee_usdt
fee = abs(safe_float(data.get("fee")) or 0.0)
+1027 -993
View File
File diff suppressed because it is too large Load Diff
+19 -2
View File
@@ -72,15 +72,32 @@ def enrich_live_unrealized(
logger.warning("live unrealized exchange overlay failed: %s", e)
option_upl = float(base.get("option_upl") or 0.0) # 期权净盈亏(本地)
# 保守预估平仓费:按现有名义×费率×2 腿,避免「刚达标、扣费后不够」
try:
from ..config import get_settings
fr = float(get_settings().fee_rate or 0.0005)
except Exception:
fr = 0.0005
notional_est = abs(float(base.get("perp_notional") or 0.0)) + abs(
float(base.get("option_notional") or 0.0)
)
if notional_est <= 0:
# 兜底:用标记价粗算
notional_est = abs(float(base.get("spot") or 0.0)) * (
abs(float(base.get("perp_qty_eth") or 0.0))
+ abs(float(base.get("option_qty_eth") or 0.0))
)
est_close = max(0.0, notional_est * fr * 2.0)
# 资金费 signed:付出为负,直接加总
net_pnl = perp_upl + option_upl - fees_paid + funding
net_pnl = perp_upl + option_upl - fees_paid + funding - est_close
out = dict(base)
out["perp_upl"] = perp_upl
out["option_upl"] = option_upl
out["fees_paid"] = fees_paid
out["funding_usdt"] = funding
out["est_close_fees"] = 0.0 # LIVE 不估平仓费
out["est_close_fees"] = est_close
out["net_pnl"] = net_pnl
out["pnl_source"] = "live_exchange"
return out
+6 -8
View File
@@ -126,9 +126,7 @@ class OkxTradeClient:
if v and v > 0:
self._ct_val_cache[inst_id] = float(v)
return float(v)
default = 0.01
self._ct_val_cache[inst_id] = default
return default
raise RuntimeError(f"OKX 无法取得合约面值 ctVal: {inst_id} instType={inst_type}")
def place_market(
self,
@@ -159,7 +157,7 @@ class OkxTradeClient:
fill = self._wait_fill(inst_id, ord_id)
return fill
def _wait_fill(self, inst_id: str, ord_id: str, *, tries: int = 8) -> LiveFill:
def _wait_fill(self, inst_id: str, ord_id: str, *, tries: int = 20) -> LiveFill:
path = f"/api/v5/trade/order?instId={inst_id}&ordId={ord_id}"
last: dict[str, Any] = {}
for _ in range(tries):
@@ -168,11 +166,11 @@ class OkxTradeClient:
last = rows[0]
state = str(last.get("state") or "")
avg = safe_float(last.get("avgPx"))
if state in ("filled", "partially_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
fee = abs(safe_float(last.get("fee")) or 0.0)
fee_ccy = str(last.get("feeCcy") or "USDT")
# 订单上 fee 常为空,再查成交明细
if fee <= 0 and ord_id:
fee, fee_ccy = self.sum_fill_fees(inst_id, ord_id)
from .money import abs_fee_usdt
@@ -188,8 +186,8 @@ class OkxTradeClient:
)
if state in ("canceled", "failed"):
raise RuntimeError(f"OKX 订单失败 state={state} {last}")
time.sleep(0.25)
raise RuntimeError(f"OKX 订单未成交 ordId={ord_id} last={last}")
time.sleep(0.3)
raise RuntimeError(f"OKX 订单未完全成交 ordId={ord_id} last={last}")
def sum_fill_fees(self, inst_id: str, ord_id: str) -> tuple[float, str]:
"""成交明细手续费合计(原币种金额, 币种)。"""
+26
View File
@@ -0,0 +1,26 @@
"""LIVE 下单用的运行时合约解析(禁止只用 env 默认 perp_inst_id)。"""
from __future__ import annotations
from ..config import Settings
from ..exchange.runtime import load_runtime_settings
def live_settings() -> Settings:
return load_runtime_settings()
def resolve_perp_inst_id(db, *, group_id: str | None = None) -> str:
"""优先组内落库合约,否则 DB/交易所默认(load_runtime_settings)。"""
s = live_settings()
if group_id:
try:
row = db.fetchone(
"SELECT perp_inst_id FROM groups WHERE group_id=?",
(group_id,),
)
if row and row["perp_inst_id"]:
return str(row["perp_inst_id"])
except Exception:
pass
return str(s.perp_inst_id)