"""开仓资金可开判定:只看交易账户(永续 USDT / 期权 USDC)。""" 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 # 不足期间只推一次;资金恢复后清零,下次再不足可再推一次 _notified_while_short: bool = False def invalidate_live_balance_cache() -> None: """兑换/划转后强制下次重拉交易账户余额。""" _live_bal_cache["ts"] = 0.0 _live_bal_cache["data"] = None 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] = { "trading_usdt": None, "trading_usdc": None, } 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 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 _live_bal_cache["data"] = dict(out) return out def _sim_balances(db: Database) -> dict[str, float]: """只看交易账户,不看资金账户。""" w = SimFundsWallets(db).view() return { "perp_usdt": float(w.get("trading_usdt") or 0), "option_usdc": float(w.get("trading_usdc") or 0), } 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, call_ask: float | None = None, put_ask: float | None = None, ) -> dict[str, Any]: """ 返回永续/期权是否有足够交易账户资金开新仓。 - 永续:交易账户 USDT >= 名义/杠杆 - 期权:交易账户 USDC >= 卖一×名义×(1+费率) - 期期:期权需 (call_ask+put_ask)×qty×(1+fee);永续视为不需要 """ global _notified_while_short db = db or get_db() s = get_settings() ledger = Ledger(db) hedge = str( ledger.get_setting_str("hedge_mode", s.hedge_mode) or s.hedge_mode ).strip().lower() if hedge not in ("perp_option", "option_option"): hedge = "perp_option" lev = float(ledger.get_setting_float("leverage", s.leverage) or 3) if lev <= 0: lev = 3.0 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_book = _index_and_option_ask() ask = float(option_ask) if option_ask is not None and float(option_ask) > 0 else ask_book if hedge == "option_option": ca = float(call_ask) if call_ask is not None and float(call_ask) > 0 else None pa = float(put_ask) if put_ask is not None and float(put_ask) > 0 else None if ca is None or pa is None: # 回退:用监控对 call/put 卖一 try: from .session import get_session snap = get_session().snapshot() if ca is None and snap.call and snap.call.ask: ca = float(snap.call.ask) if pa is None and snap.put and snap.put.ask: pa = float(snap.put.ask) except Exception: pass cush = float( ledger.get_setting_float("oo_budget_cushion", s.oo_budget_cushion) or s.oo_budget_cushion ) cush = min(1.0, max(0.5, cush)) if ca is not None and pa is not None and ca > 0 and pa > 0: # 与定仓一致:按预留后的权利金需求估资金门 premium_need = (ca + pa) * opt_qty * (1.0 + fee_rate) * cush else: premium_need = None margin_need = 0.0 perp_qty = 0.0 else: 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_usdt = live.get("trading_usdt") t_usdc = live.get("trading_usdc") have_perp = float(t_usdt) if t_usdt is not None else None have_opt = float(t_usdc) if t_usdc is not None else None perp_ok: bool | None if hedge == "option_option": perp_ok = True elif margin_need is None or have_perp is None: perp_ok = None else: perp_ok = float(have_perp) + 1e-9 >= float(margin_need) opt_ok: bool | None if premium_need is None or have_opt is None: opt_ok = None else: opt_ok = float(have_opt) + 1e-9 >= float(premium_need) if hedge == "option_option": funds_ok = opt_ok is True else: funds_ok = perp_ok is True and opt_ok is True # 资金恢复后允许下次不足再通知一次 if funds_ok: _notified_while_short = False lev_i = int(round(lev)) if abs(lev - round(lev)) < 1e-9 else lev if hedge == "option_option": perp_label = "永续 —(期期)" elif 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 { "hedge_mode": hedge, "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": funds_ok, "source": "trading", } def funds_gate_blocks(cap: dict[str, Any] | None) -> tuple[bool, str]: """ Fail-closed:永期需永续+期权均为 True;期期仅需期权为 True。 None(未知)或 False → 拦截。 """ if not cap: return True, "资金可开判定结果为空,拒绝开仓" hedge = str(cap.get("hedge_mode") or "perp_option").strip().lower() if hedge == "option_option": if cap.get("option_can_open") is not True: detail = ( f"{cap.get('option_label')};" f"期权需≈{cap.get('option_need_usdc')}U/有{cap.get('option_have_usdc')}U" ) if cap.get("option_can_open") is None: detail += "(余额/盘口未知,fail-closed 拒绝开仓)" return True, f"资金不足或状态未知,暂不可开新仓:{detail}" return False, "" 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 cap = cap or assess_open_capacity() # 能开 / 未知:不通知;资金恢复则重置,便于下次不足再提醒一次 if cap.get("funds_ok") is True: _notified_while_short = False return cannot = cap.get("perp_can_open") is False or cap.get("option_can_open") is False if not cannot: return if _notified_while_short: return _notified_while_short = True parts: list[str] = [] if cap.get("perp_can_open") is False: parts.append( f"交易账户 USDT 不足:需约 {cap.get('perp_need_usdt')}U,现有 {cap.get('perp_have_usdt')}U" ) if cap.get("option_can_open") is False: parts.append( f"交易账户 USDC 不足:需约 {cap.get('option_need_usdc')}U,现有 {cap.get('option_have_usdc')}U" ) 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], "OKX 开仓前会尝试交易账户市价兑 USDC;仍不足请检查交易账户余额。", ], ) ) except Exception: logger.exception("wecom funds notify failed")