diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index bfb67e0..c2ad36d 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -17,6 +17,7 @@ from ..env_store import live_ready from .clock import can_open_new, window_key from .exits import check_expiry_close, check_exits, resolve_exit_target from .group import next_group_id +from .open_capacity import assess_open_capacity, maybe_notify_funds_short logger = logging.getLogger(__name__) @@ -101,6 +102,23 @@ class StrategyEngine: self._set_state(last_error=None) last_error = None allow_open = can_open_new(skip_weekends=skip_weekends) + try: + open_cap = assess_open_capacity(self.db) + except Exception: + logger.exception("assess_open_capacity failed") + open_cap = { + "perp_can_open": None, + "option_can_open": None, + "perp_label": f"永续{int(round(leverage))}x —", + "option_label": "期权 —", + "funds_ok": False, + "leverage": leverage, + } + if open_cap.get("perp_can_open") is False or open_cap.get("option_can_open") is False: + try: + maybe_notify_funds_short(open_cap) + except Exception: + pass return { "running": bool(row["running"]), "phase": row["phase"], @@ -120,6 +138,7 @@ class StrategyEngine: "atm_open_offset_enabled": atm_off_on, "max_atm_open_offset": max_atm_off, "can_open": allow_open, + "open_capacity": open_cap, "last_error": last_error, "position": upl, "residuals": self.matcher.list_residual_options(pending_only=True), @@ -593,6 +612,22 @@ class StrategyEngine: self._set_state(phase="open", last_error="有未平仓,禁止开下一组") return + 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") + self._set_state(phase="wait_signal") pick = await get_session().pick_for_open_async() if pick is None: diff --git a/backend/app/strategy/open_capacity.py b/backend/app/strategy/open_capacity.py new file mode 100644 index 0000000..5335efc --- /dev/null +++ b/backend/app/strategy/open_capacity.py @@ -0,0 +1,231 @@ +"""开仓资金可开判定:永续保证金 + 期权权利金。""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from ..config import get_settings +from ..models.db import Database, get_db +from ..sim.funds_wallets import SimFundsWallets +from ..sim.ledger import Ledger + +logger = logging.getLogger(__name__) + +_live_bal_cache: dict[str, Any] = {"ts": 0.0, "data": None} +_LIVE_BAL_TTL_SEC = 8.0 + +_last_notify_key: str | None = None +_last_notify_ms: float = 0.0 +_NOTIFY_DEDUP_SEC = 600.0 # 同状态 10 分钟内不重复推 + + +def _f(v: Any) -> float | None: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def _index_and_option_ask() -> tuple[float | None, float | None]: + """指数价 + 期权卖一粗估(取 Call/Put 卖一较大者,偏保守)。""" + try: + from .session import get_session + + snap = get_session().snapshot() + except Exception: + return None, None + idx = _f(getattr(snap, "index_px", None)) + if idx is None and snap.perp: + idx = _f(snap.perp.mark_px) or _f(snap.perp.ask) or _f(snap.perp.bid) + asks: list[float] = [] + for leg in (snap.call, snap.put): + if leg is None: + continue + a = _f(leg.ask) + if a is not None and a > 0: + asks.append(a) + ask = max(asks) if asks else None + return idx, ask + + +def _live_balances() -> dict[str, float | None]: + now = time.time() + if _live_bal_cache["data"] is not None and now - float(_live_bal_cache["ts"]) < _LIVE_BAL_TTL_SEC: + return dict(_live_bal_cache["data"]) + out: dict[str, float | None] = { + "funding_usdt": None, + "trading_usdt": None, + "options_funding_usdc": None, + "options_trading_usdc": None, + } + try: + from ..live.okx_funds import OkxFundsClient + + client = OkxFundsClient() + try: + bal = client.fetch_balances() + for k in out: + out[k] = _f(bal.get(k)) + finally: + client.close() + except Exception as e: + logger.warning("open_capacity live balance failed: %s", e) + _live_bal_cache["ts"] = now + _live_bal_cache["data"] = dict(out) + return out + + +def _sim_balances(db: Database) -> dict[str, float]: + w = SimFundsWallets(db).snapshot() + led = Ledger(db).snapshot() + avail = float(led.get("available") or 0) + funding = float(w.get("funding_usdt") or 0) + trading = float(w.get("trading_usdt") or 0) + opt_f = float(w.get("options_funding_usdc") or 0) + opt_t = float(w.get("options_trading_usdc") or 0) + # 钱包未播种时回退账本可用 + usdt = funding + trading + if usdt < 1e-9: + usdt = avail + usdc = opt_f + opt_t + if usdc < 1e-9: + # SIM 早期权利金从账本扣;无 USDC 钱包时用可用资金估期权可开 + usdc = avail + return { + "perp_usdt": usdt, + "option_usdc": usdc, + "ledger_available": avail, + } + + +def assess_open_capacity(db: Database | None = None) -> dict[str, Any]: + """ + 返回永续/期权是否有足够资金开新仓。 + - 永续:需 USDT >= 名义/杠杆 + - 期权:需 USDC(或 SIM 回退可用) >= 卖一×名义×(1+费率) + """ + db = db or get_db() + s = get_settings() + ledger = Ledger(db) + 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) + fee_rate = float(ledger.get_setting_float("fee_rate", s.fee_rate) or 0.0005) + + idx, ask = _index_and_option_ask() + 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 + ) + + if s.is_sim: + bal = _sim_balances(db) + have_perp = float(bal["perp_usdt"]) + have_opt = float(bal["option_usdc"]) + else: + live = _live_balances() + # 永续用交易账户;若为空则合并资金账户(提示需划转但仍可显示) + t = live.get("trading_usdt") + f = live.get("funding_usdt") + if t is not None and t > 1e-9: + have_perp = float(t) + elif t is not None or f is not None: + have_perp = float(t or 0) + float(f or 0) + else: + have_perp = None + of_ = live.get("options_funding_usdc") + ot_ = live.get("options_trading_usdc") + if of_ is None and ot_ is None: + have_opt = None + else: + have_opt = float(of_ or 0) + float(ot_ or 0) + + perp_ok: bool | None + if margin_need is None or have_perp is None: + perp_ok = None + else: + perp_ok = have_perp + 1e-9 >= margin_need + + opt_ok: bool | None + if premium_need is None or have_opt is None: + opt_ok = None + else: + opt_ok = have_opt + 1e-9 >= premium_need + + lev_i = int(round(lev)) if abs(lev - round(lev)) < 1e-9 else lev + if perp_ok is True: + perp_label = f"永续{lev_i}x 可开" + elif perp_ok is False: + perp_label = f"永续{lev_i}x 不可开" + else: + perp_label = f"永续{lev_i}x —" + + if opt_ok is True: + opt_label = "期权可开" + elif opt_ok is False: + opt_label = "期权不可开" + else: + opt_label = "期权 —" + + return { + "leverage": lev, + "perp_qty_eth": perp_qty, + "option_qty_eth": opt_qty, + "index_px": idx, + "option_ask": ask, + "perp_need_usdt": round(margin_need, 2) if margin_need is not None else None, + "option_need_usdc": round(premium_need, 2) if premium_need is not None else None, + "perp_have_usdt": round(have_perp, 2) if have_perp is not None else None, + "option_have_usdc": round(have_opt, 2) if have_opt is not None else None, + "perp_can_open": perp_ok, + "option_can_open": opt_ok, + "perp_label": perp_label, + "option_label": opt_label, + "funds_ok": (perp_ok is True and opt_ok is True), + } + + +def maybe_notify_funds_short(cap: dict[str, Any] | None = None) -> None: + """资金不足时企业微信推送(去重)。""" + global _last_notify_key, _last_notify_ms + cap = cap or assess_open_capacity() + parts: list[str] = [] + if cap.get("perp_can_open") is False: + parts.append( + f"永续不足:需约 {cap.get('perp_need_usdt')}U,现有 {cap.get('perp_have_usdt')}U" + ) + if cap.get("option_can_open") is False: + parts.append( + f"期权不足:需约 {cap.get('option_need_usdc')}U,现有 {cap.get('option_have_usdc')}U" + ) + if not parts: + return + key = "|".join(parts) + now = time.time() + if key == _last_notify_key and now - _last_notify_ms < _NOTIFY_DEDUP_SEC: + return + _last_notify_key = key + _last_notify_ms = now + try: + from ..notify import wecom + + wecom.notify_async( + wecom.build_markdown( + tag=wecom.TAG_FAULT, + title="资金不足 · 无法开新仓", + lines=[ + f"**永续**: {cap.get('perp_label')}", + f"**期权**: {cap.get('option_label')}", + *[f"**详情**: {p}" for p in parts], + "请划转/兑换后重试。", + ], + ) + ) + except Exception: + logger.exception("wecom funds notify failed") diff --git a/docs/更新说明.md b/docs/更新说明.md index 649f346..e22458e 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,16 @@ --- +## 2026-07-29 — 权益杠杆可开判定 + 资金不足微信推送 + +### 变更 + +1. 计划页「权益 / 杠杆」改为 **永续Nx 可开 / 期权可开**(绿)或 **不可开**(红);按保证金与权利金估算对比可用资金。 +2. 资金不足时企业微信推送(10 分钟去重);策略开仓前拦截并进入 `wait_funds`。 +3. 「规则说明」移到启动策略按钮上方;顶栏资金数字加大显示。 + +--- + ## 2026-07-29 — 顶栏对齐 crypto_monitor:标题在上、资金卡同宽 ### 变更 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 144b2d9..d85982e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -233,6 +233,18 @@ export type PlanState = { atm_open_offset_enabled?: boolean; max_atm_open_offset?: number; can_open: boolean; + open_capacity?: { + leverage?: number; + perp_can_open?: boolean | null; + option_can_open?: boolean | null; + perp_label?: string; + option_label?: string; + funds_ok?: boolean; + perp_need_usdt?: number | null; + option_need_usdc?: number | null; + perp_have_usdt?: number | null; + option_have_usdc?: number | null; + }; last_error: string | null; show_manual_trade_buttons?: boolean; position: { diff --git a/frontend/src/components/FundsBar.tsx b/frontend/src/components/FundsBar.tsx index 1b52d70..2e3f12b 100644 --- a/frontend/src/components/FundsBar.tsx +++ b/frontend/src/components/FundsBar.tsx @@ -31,7 +31,6 @@ function pnlClass(n: number | null | undefined) { export default function FundsBar() { const [s, setS] = useState(null); - const [rulesOpen, setRulesOpen] = useState(false); const load = useCallback(() => { apiFetch("/api/funds/summary") @@ -49,10 +48,6 @@ export default function FundsBar() { if (!s?.ok) return null; - const rulesText = `行情 ${s.exchange || "OKX"} · ${ - s.perp_inst_id || "ETH-USDT-SWAP" - } · 目标平仓(双腿/远虚只平永续) · 未达标则到期结算`; - return (
@@ -83,50 +78,33 @@ export default function FundsBar() { : "—"}
-
+
总资金
{fmtU(s.total_funds)}
-
+
资金账户
{fmtU(s.funding_usdt)}
-
+
交易账户
{fmtU(s.trading_usdt)}
-
+
期权资金账户
{s.options_funding_label}
-
+
期权交易账户
{s.options_trading_label}
-
+
实时盈亏
{s.realtime_pnl == null ? "—" : fmtU(s.realtime_pnl)}
- -
- - {rulesOpen ? ( -

{rulesText}

- ) : null} -
); diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index cc35d47..9e2c0cb 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -80,6 +80,7 @@ const PHASE_ZH: Record = { outside_window: "窗外", liquidity_wait: "流动性等待", weekend_skip: "周末跳过", + wait_funds: "资金不足", }; export default function PlanPage() { @@ -213,6 +214,16 @@ export default function PlanPage() { {err ?
{err}
: null} +
+
+ 规则说明 +

+ 行情 {String(plan?.exchange || "okx").toUpperCase()} · ETH-USDT-SWAP · + 目标平仓(双腿/远虚只平永续) · 未达标则到期结算 +

+
+
+