Files
eth_hedge_sim/backend/app/strategy/open_capacity.py
T

261 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""开仓资金可开判定:只看交易账户(永续 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,
) -> dict[str, Any]:
"""
返回永续/期权是否有足够交易账户资金开新仓。
- 永续:交易账户 USDT >= 名义/杠杆
- 期权:交易账户 USDC >= 卖一×名义×(1+费率)
可选覆盖 ask/名义(选约后应用选中腿卖一,避免与 max(call,put) 打架)。
"""
global _notified_while_short
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(
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
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 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
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 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": funds_ok,
"source": "trading",
}
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
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")