Harden strategy SoT: fail-closed funds gate, shared open pipeline, LIVE switch guards.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""OKX 开仓前:交易账户 USDC 不足时市价 USDT→USDC(目标=期权所需×2)。
|
||||
"""OKX:交易账户 USDC 不足时市价 USDT→USDC(目标=期权所需×2)。
|
||||
|
||||
仅 OKX SIM/LIVE。资金账户不参与;期权已可开则跳过。
|
||||
空仓等待开仓时也会按盘口刷新以损定仓名义后再检测,不依赖已选中合格期权。
|
||||
等待选约阶段只用预览名义检测(不落库);落库定仓在 open_pipeline.size_and_gate。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,11 +17,8 @@ from .open_capacity import assess_open_capacity, invalidate_live_balance_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 目标持仓 = 期权开仓所需 USDC × 倍数
|
||||
_TARGET_MULTIPLE = 2.0
|
||||
# 过小不兑(避免粉尘单)
|
||||
_MIN_CONVERT_USDT = 1.0
|
||||
# 不足时重试间隔,避免每秒砸单
|
||||
_RETRY_COOLDOWN_SEC = 45.0
|
||||
_last_attempt_ts: float = 0.0
|
||||
|
||||
@@ -45,42 +42,40 @@ def _round_down(n: float, nd: int = 2) -> float:
|
||||
return math.floor(n * f + 1e-12) / f
|
||||
|
||||
|
||||
def refresh_risk_sizing_from_market(db: Database | None = None) -> dict[str, Any]:
|
||||
"""空仓时用当前指数/卖一刷新以损定仓名义,便于资金门与自动兑对齐展示。"""
|
||||
def preview_capacity_for_convert(db: Database | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
用盘口预览以损定仓名义评估资金门(不写 settings)。
|
||||
手动仓位则直接 assess 当前账本名义。
|
||||
"""
|
||||
database = db or get_db()
|
||||
out: dict[str, Any] = {"ok": True, "detail": "skip"}
|
||||
try:
|
||||
from ..sim.ledger import Ledger
|
||||
from .open_capacity import _index_and_option_ask
|
||||
from .risk_sizing import apply_risk_sizing_to_ledger, is_risk_based
|
||||
from .risk_sizing import preview_risk_sizing
|
||||
|
||||
if not is_risk_based(Ledger(database)):
|
||||
out["detail"] = "manual_sizing"
|
||||
return out
|
||||
idx, ask = _index_and_option_ask()
|
||||
if idx is None or ask is None or float(idx) <= 0 or float(ask) <= 0:
|
||||
out["ok"] = False
|
||||
out["detail"] = "暂无指数或期权卖一"
|
||||
return out
|
||||
r = apply_risk_sizing_to_ledger(
|
||||
index_px=float(idx), option_ask=float(ask), db=database
|
||||
)
|
||||
out["ok"] = bool(r.ok)
|
||||
out["detail"] = r.detail
|
||||
out["k"] = r.k
|
||||
out["option_qty_eth"] = r.option_qty_eth
|
||||
return out
|
||||
except Exception as e:
|
||||
logger.exception("refresh risk sizing for auto_usdc failed")
|
||||
return {"ok": False, "detail": str(e)}
|
||||
prev = preview_risk_sizing(database)
|
||||
if prev.get("risk_based") and prev.get("ok"):
|
||||
return assess_open_capacity(
|
||||
database,
|
||||
option_ask=float(prev["option_ask"])
|
||||
if prev.get("option_ask") is not None
|
||||
else None,
|
||||
option_qty_eth=float(prev["option_qty_eth"])
|
||||
if prev.get("option_qty_eth") is not None
|
||||
else None,
|
||||
perp_qty_eth=float(prev["perp_qty_eth"])
|
||||
if prev.get("perp_qty_eth") is not None
|
||||
else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("preview capacity for convert failed")
|
||||
return assess_open_capacity(database)
|
||||
|
||||
|
||||
def prepare_okx_trading_usdc(db: Database | None = None) -> dict[str, Any]:
|
||||
"""选约前也可调用:先刷新名义,再按资金门自动兑 USDC。"""
|
||||
"""等待阶段:预览名义评估 + 兑换,不落库。"""
|
||||
database = db or get_db()
|
||||
sized = refresh_risk_sizing_from_market(database)
|
||||
top = ensure_okx_trading_usdc(database)
|
||||
return {"sizing": sized, "convert": top}
|
||||
cap = preview_capacity_for_convert(database)
|
||||
conv = ensure_okx_trading_usdc(database, cap=cap, force=False)
|
||||
return {"capacity": cap, "convert": conv}
|
||||
|
||||
|
||||
def ensure_okx_trading_usdc(
|
||||
@@ -90,12 +85,13 @@ def ensure_okx_trading_usdc(
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
开仓资金门前调用:
|
||||
- 非 OKX → 跳过
|
||||
- 期权可开(USDC≥需)→ 跳过
|
||||
- 否则在**交易账户**市价 USDT→USDC,尽量补到 需×2(并预留永续保证金)
|
||||
- 否则交易账户市价 USDT→USDC,尽量补到 需×2(预留永续保证金)
|
||||
- force 不再绕过冷却(防砸单);保留参数仅为兼容调用方
|
||||
"""
|
||||
global _last_attempt_ts
|
||||
_ = force # 明确忽略:冷却始终生效
|
||||
db = db or get_db()
|
||||
out: dict[str, Any] = {
|
||||
"ok": True,
|
||||
@@ -121,7 +117,6 @@ def ensure_okx_trading_usdc(
|
||||
out["detail"] = "期权所需为 0,跳过"
|
||||
return out
|
||||
|
||||
# 可开仓:不兑换(即使低于 2 倍目标)
|
||||
if have_f + 1e-9 >= need_f:
|
||||
out["detail"] = (
|
||||
f"交易账户 USDC 已够开仓(有 {have_f:.2f} ≥ 需 {need_f:.2f}),不兑换"
|
||||
@@ -130,11 +125,7 @@ def ensure_okx_trading_usdc(
|
||||
return out
|
||||
|
||||
now = time.time()
|
||||
if (
|
||||
not force
|
||||
and _last_attempt_ts > 0
|
||||
and now - _last_attempt_ts < _RETRY_COOLDOWN_SEC
|
||||
):
|
||||
if _last_attempt_ts > 0 and now - _last_attempt_ts < _RETRY_COOLDOWN_SEC:
|
||||
left = _RETRY_COOLDOWN_SEC - (now - _last_attempt_ts)
|
||||
out["detail"] = f"USDC 不足,自动兑换冷却中({left:.0f}s)"
|
||||
out["capacity"] = cap
|
||||
@@ -153,12 +144,10 @@ def ensure_okx_trading_usdc(
|
||||
rate = float(usdc_usdt_mid_rate() or 1.0)
|
||||
if rate <= 0:
|
||||
rate = 1.0
|
||||
# usdt_to_usdc:amount = 花费的 USDT(与 OkxFundsClient / SIM 一致)
|
||||
want_usdt = gap_usdc * rate
|
||||
|
||||
perp_need = float(cap.get("perp_need_usdt") or 0)
|
||||
trading_usdt = float(cap.get("perp_have_usdt") or 0)
|
||||
# 预留永续保证金,避免兑光导致永续不可开
|
||||
spendable = max(0.0, trading_usdt - max(0.0, perp_need))
|
||||
spend_usdt = _round_down(min(want_usdt, spendable), 2)
|
||||
|
||||
|
||||
@@ -807,33 +807,28 @@ class StrategyEngine:
|
||||
self._set_state(phase="open", last_error="有未平仓,禁止开下一组")
|
||||
return
|
||||
|
||||
# OKX:先按盘口刷新名义并检测 USDC;不够则交易账户市价兑换(不依赖已选中合格期权)
|
||||
# OKX:等待选约时仅预览名义+兑 USDC(不落库改 qty/exit)
|
||||
try:
|
||||
from .auto_usdc import prepare_okx_trading_usdc
|
||||
from .open_pipeline import prepare_usdc_while_waiting
|
||||
|
||||
prep = prepare_okx_trading_usdc(self.db)
|
||||
prep = prepare_usdc_while_waiting(self.db)
|
||||
conv = prep.get("convert") or {}
|
||||
if conv.get("acted"):
|
||||
self._set_state(
|
||||
last_error=None,
|
||||
phase="wait_signal",
|
||||
)
|
||||
logger.info("auto_usdc prepared: %s", conv.get("detail"))
|
||||
elif conv.get("ok") is False and "不足" in str(conv.get("detail") or ""):
|
||||
# 保留资金提示,但仍继续尝试选约(可能只是冷却/短暂失败)
|
||||
logger.warning("auto_usdc: %s", conv.get("detail"))
|
||||
logger.info("auto_usdc while waiting: %s", conv.get("detail"))
|
||||
elif conv.get("ok") is False:
|
||||
logger.warning("auto_usdc while waiting: %s", conv.get("detail"))
|
||||
except Exception:
|
||||
logger.exception("prepare OKX USDC failed")
|
||||
logger.exception("prepare USDC while waiting failed")
|
||||
|
||||
self._set_state(phase="wait_signal")
|
||||
pick = await get_session().pick_for_open_async()
|
||||
if pick is None:
|
||||
# 若期权仍不可开且刚才未兑成功,把资金状态写进错误,便于排查「为何没自动兑」
|
||||
try:
|
||||
from .open_capacity import assess_open_capacity
|
||||
from .open_capacity import assess_open_capacity, funds_gate_blocks
|
||||
|
||||
cap = assess_open_capacity(self.db)
|
||||
if cap.get("option_can_open") is False:
|
||||
blocked, why = funds_gate_blocks(cap)
|
||||
if blocked and cap.get("option_can_open") is False:
|
||||
self._set_state(
|
||||
phase="wait_funds",
|
||||
last_error=(
|
||||
@@ -843,6 +838,12 @@ class StrategyEngine:
|
||||
),
|
||||
)
|
||||
return
|
||||
if blocked and cap.get("option_can_open") is None:
|
||||
self._set_state(
|
||||
phase="wait_funds",
|
||||
last_error=why,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
self._set_state(
|
||||
@@ -850,56 +851,33 @@ class StrategyEngine:
|
||||
)
|
||||
return
|
||||
|
||||
# 以损定仓:每笔开仓前按指数/卖一重算 k,再写名义与出场(须在资金门前)
|
||||
try:
|
||||
from .risk_sizing import apply_risk_sizing_to_ledger
|
||||
# 选约后:定仓落库 → 兑 USDC → 资金门 fail-closed(与手动开仓同一管道)
|
||||
from .open_pipeline import size_and_gate
|
||||
|
||||
rs = apply_risk_sizing_to_ledger(
|
||||
index_px=float(pick.underlying_px),
|
||||
option_ask=float(pick.option_ask),
|
||||
db=self.db,
|
||||
)
|
||||
if not rs.ok:
|
||||
self._set_state(phase="idle", last_error=rs.detail)
|
||||
prep = size_and_gate(
|
||||
index_px=float(pick.underlying_px),
|
||||
option_ask=float(pick.option_ask),
|
||||
db=self.db,
|
||||
)
|
||||
if not prep.ok:
|
||||
phase = "wait_funds" if prep.capacity is not None else "idle"
|
||||
self._set_state(phase=phase, last_error=prep.detail)
|
||||
if prep.capacity is not None:
|
||||
maybe_notify_funds_short(prep.capacity)
|
||||
if "以损定仓" in (prep.detail or ""):
|
||||
try:
|
||||
from ..notify import wecom
|
||||
|
||||
wecom.notify_fault(
|
||||
title="以损定仓失败",
|
||||
detail=rs.detail,
|
||||
dedupe_key=f"risk_sizing:{rs.detail[:80]}",
|
||||
detail=prep.detail,
|
||||
dedupe_key=f"risk_sizing:{prep.detail[:80]}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("risk sizing failed")
|
||||
self._set_state(phase="idle", last_error="以损定仓计算异常,暂不开仓")
|
||||
return
|
||||
|
||||
# 选约后名义可能变化,再检一次 USDC(force 跳过冷却,避免刚选完仍差一截)
|
||||
try:
|
||||
from .auto_usdc import ensure_okx_trading_usdc
|
||||
|
||||
ensure_okx_trading_usdc(self.db, force=True)
|
||||
except Exception:
|
||||
logger.exception("auto USDC top-up failed")
|
||||
|
||||
try:
|
||||
cap = assess_open_capacity(self.db)
|
||||
if cap.get("perp_can_open") is False or cap.get("option_can_open") is False:
|
||||
detail = (
|
||||
f"{cap.get('perp_label')} · {cap.get('option_label')};"
|
||||
f"永续需≈{cap.get('perp_need_usdt')}U/有{cap.get('perp_have_usdt')}U,"
|
||||
f"期权需≈{cap.get('option_need_usdc')}U/有{cap.get('option_have_usdc')}U"
|
||||
)
|
||||
self._set_state(phase="wait_funds", last_error=f"资金不足,暂不可开新仓:{detail}")
|
||||
maybe_notify_funds_short(cap)
|
||||
return
|
||||
if st["phase"] == "wait_funds":
|
||||
self._set_state(phase="idle", last_error=None)
|
||||
except Exception:
|
||||
logger.exception("open capacity gate failed")
|
||||
if st["phase"] == "wait_funds":
|
||||
self._set_state(phase="idle", last_error=None)
|
||||
|
||||
self._set_state(phase="opening", last_error=None)
|
||||
wkey = window_key()
|
||||
|
||||
@@ -98,11 +98,18 @@ def _sim_balances(db: Database) -> dict[str, float]:
|
||||
}
|
||||
|
||||
|
||||
def assess_open_capacity(db: Database | None = None) -> dict[str, Any]:
|
||||
def assess_open_capacity(
|
||||
db: Database | None = None,
|
||||
*,
|
||||
option_ask: float | None = None,
|
||||
option_qty_eth: float | None = None,
|
||||
perp_qty_eth: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
返回永续/期权是否有足够交易账户资金开新仓。
|
||||
- 永续:交易账户 USDT >= 名义/杠杆
|
||||
- 期权:交易账户 USDC >= 卖一×名义×(1+费率)
|
||||
可选覆盖 ask/名义(选约后应用选中腿卖一,避免与 max(call,put) 打架)。
|
||||
"""
|
||||
global _notified_while_short
|
||||
db = db or get_db()
|
||||
@@ -111,11 +118,20 @@ def assess_open_capacity(db: Database | None = None) -> dict[str, Any]:
|
||||
lev = float(ledger.get_setting_float("leverage", s.leverage) or 3)
|
||||
if lev <= 0:
|
||||
lev = 3.0
|
||||
perp_qty = float(ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth) or 1)
|
||||
opt_qty = float(ledger.get_setting_float("option_qty_eth", s.option_qty_eth) or 2)
|
||||
perp_qty = float(
|
||||
perp_qty_eth
|
||||
if perp_qty_eth is not None
|
||||
else (ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth) or 1)
|
||||
)
|
||||
opt_qty = float(
|
||||
option_qty_eth
|
||||
if option_qty_eth is not None
|
||||
else (ledger.get_setting_float("option_qty_eth", s.option_qty_eth) or 2)
|
||||
)
|
||||
fee_rate = float(ledger.get_setting_float("fee_rate", s.fee_rate) or 0.0005)
|
||||
|
||||
idx, ask = _index_and_option_ask()
|
||||
idx, ask_book = _index_and_option_ask()
|
||||
ask = float(option_ask) if option_ask is not None and float(option_ask) > 0 else ask_book
|
||||
margin_need = (float(idx) * perp_qty / lev) if idx and idx > 0 else None
|
||||
premium_need = (
|
||||
float(ask) * opt_qty * (1.0 + fee_rate) if ask is not None and ask > 0 else None
|
||||
@@ -183,6 +199,25 @@ def assess_open_capacity(db: Database | None = None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def funds_gate_blocks(cap: dict[str, Any] | None) -> tuple[bool, str]:
|
||||
"""
|
||||
Fail-closed:仅当永续与期权均为 True 才放行。
|
||||
None(未知,如币安未接余额)或 False → 拦截。
|
||||
"""
|
||||
if not cap:
|
||||
return True, "资金可开判定结果为空,拒绝开仓"
|
||||
if cap.get("perp_can_open") is not True or cap.get("option_can_open") is not True:
|
||||
detail = (
|
||||
f"{cap.get('perp_label')} · {cap.get('option_label')};"
|
||||
f"永续需≈{cap.get('perp_need_usdt')}U/有{cap.get('perp_have_usdt')}U,"
|
||||
f"期权需≈{cap.get('option_need_usdc')}U/有{cap.get('option_have_usdc')}U"
|
||||
)
|
||||
if cap.get("perp_can_open") is None or cap.get("option_can_open") is None:
|
||||
detail += "(余额/盘口未知,fail-closed 拒绝开仓)"
|
||||
return True, f"资金不足或状态未知,暂不可开新仓:{detail}"
|
||||
return False, ""
|
||||
|
||||
|
||||
def maybe_notify_funds_short(cap: dict[str, Any] | None = None) -> None:
|
||||
"""仅在「不能开」时推送一次;能开绝不通知。"""
|
||||
global _notified_while_short
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""开仓统一管道:定仓 → 兑 USDC → 资金门(fail-closed)。
|
||||
|
||||
策略自动开仓与手动开一组共用,避免双路径打架。
|
||||
基线假设:运行中不在交易所人工开平仓、也不人工手动平仓。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ..models.db import Database, get_db
|
||||
from .open_capacity import assess_open_capacity, funds_gate_blocks
|
||||
from .risk_sizing import apply_risk_sizing_to_ledger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OpenPrepResult:
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
capacity: dict[str, Any] | None = None
|
||||
sizing_detail: str = ""
|
||||
convert_detail: str = ""
|
||||
|
||||
|
||||
def prepare_usdc_while_waiting(db: Database | None = None) -> dict[str, Any]:
|
||||
"""空仓等待选约:预览名义检测 USDC 并兑换,不落库改写 qty/exit。"""
|
||||
from .auto_usdc import prepare_okx_trading_usdc
|
||||
|
||||
return prepare_okx_trading_usdc(db)
|
||||
|
||||
|
||||
def size_and_gate(
|
||||
*,
|
||||
index_px: float,
|
||||
option_ask: float,
|
||||
db: Database | None = None,
|
||||
) -> OpenPrepResult:
|
||||
"""
|
||||
选约成功后:写入以损定仓 → 交易账户兑 USDC → 资金门。
|
||||
资金门 fail-closed:异常 / can_open 非 True 一律拦截。
|
||||
"""
|
||||
database = db or get_db()
|
||||
try:
|
||||
rs = apply_risk_sizing_to_ledger(
|
||||
index_px=float(index_px),
|
||||
option_ask=float(option_ask),
|
||||
db=database,
|
||||
)
|
||||
if not rs.ok:
|
||||
return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail)
|
||||
except Exception as e:
|
||||
logger.exception("risk sizing failed in open pipeline")
|
||||
return OpenPrepResult(ok=False, detail=f"以损定仓计算异常:{e}")
|
||||
|
||||
convert_detail = ""
|
||||
try:
|
||||
from .auto_usdc import ensure_okx_trading_usdc
|
||||
|
||||
# 选约后名义已更新;仍受冷却约束,禁止 force 砸单
|
||||
conv = ensure_okx_trading_usdc(
|
||||
database,
|
||||
cap=assess_open_capacity(
|
||||
database,
|
||||
option_ask=float(option_ask),
|
||||
),
|
||||
force=False,
|
||||
)
|
||||
convert_detail = str(conv.get("detail") or "")
|
||||
if conv.get("acted"):
|
||||
logger.info("open_pipeline auto_usdc: %s", convert_detail)
|
||||
except Exception:
|
||||
logger.exception("auto USDC in open pipeline failed")
|
||||
convert_detail = "自动兑 USDC 异常(已记日志)"
|
||||
|
||||
try:
|
||||
cap = assess_open_capacity(database, option_ask=float(option_ask))
|
||||
except Exception as e:
|
||||
logger.exception("open capacity assess failed")
|
||||
return OpenPrepResult(
|
||||
ok=False,
|
||||
detail=f"资金可开判定失败,拒绝开仓:{e}",
|
||||
convert_detail=convert_detail,
|
||||
)
|
||||
|
||||
blocked, why = funds_gate_blocks(cap)
|
||||
if blocked:
|
||||
return OpenPrepResult(
|
||||
ok=False,
|
||||
detail=why or "资金不足或状态未知,拒绝开仓",
|
||||
capacity=cap,
|
||||
convert_detail=convert_detail,
|
||||
)
|
||||
return OpenPrepResult(
|
||||
ok=True,
|
||||
detail="ready",
|
||||
capacity=cap,
|
||||
convert_detail=convert_detail,
|
||||
)
|
||||
Reference in New Issue
Block a user