"""以损定仓:按可承受最大亏损反推标准组倍数 k(永续1 / 期权2 / 出场15)。""" from __future__ import annotations import logging import math from dataclasses import dataclass from typing import Any from ..config import get_settings from ..models.db import Database, get_db from ..sim.ledger import Ledger logger = logging.getLogger(__name__) # 标准组基准默认(k=1);可由设置 risk_*_unit 覆盖 BASE_PERP_ETH = 1.0 BASE_OPTION_ETH = 2.0 BASE_EXIT_USDT = 15.0 MIN_K = 0.1 FEE_LEG_COUNT = 3 # 永续开/平 + 期权一次 def _round2(x: float | None) -> float | None: if x is None or not math.isfinite(float(x)): return None return round(float(x) + 0.0, 2) def read_risk_units(ledger: Ledger) -> tuple[float, float, float]: """永续名义单位 / 期权名义单位 / 出场基数(k=1)。""" perp_u = ledger.get_setting_float("risk_perp_unit", BASE_PERP_ETH) opt_u = ledger.get_setting_float("risk_option_unit", BASE_OPTION_ETH) exit_u = ledger.get_setting_float("risk_exit_unit", BASE_EXIT_USDT) if perp_u <= 0: perp_u = BASE_PERP_ETH if opt_u <= 0: opt_u = BASE_OPTION_ETH if exit_u <= 0: exit_u = BASE_EXIT_USDT return float(perp_u), float(opt_u), float(exit_u) @dataclass(frozen=True, slots=True) class RiskSizingResult: ok: bool detail: str k: float | None = None budget: float | None = None capital_base: float | None = None premium_est: float | None = None fee_est: float | None = None max_loss: float | None = None perp_qty_eth: float | None = None option_qty_eth: float | None = None net_profit_target: float | None = None index_px: float | None = None option_ask: float | None = None perp_unit: float | None = None option_unit: float | None = None exit_unit: float | None = None def is_risk_based(ledger: Ledger | None = None) -> bool: led = ledger or Ledger() mode = (led.get_setting_str("sizing_mode", "manual") or "manual").strip().lower() return mode == "risk_based" def floor_k_1dp(k_raw: float) -> float: """一位小数向下取整,保证不超预算。""" if k_raw <= 0 or not math.isfinite(k_raw): return 0.0 return math.floor(k_raw * 10.0 + 1e-12) / 10.0 def unit_cost( *, index_px: float, option_ask: float, fee_rate: float, option_unit: float = BASE_OPTION_ETH, ) -> float: """k=1 时估算最大亏损 = 权利金(option_unit ETH) + 手续费粗估。""" premium_unit = float(option_ask) * float(option_unit) fee_unit = float(index_px) * float(fee_rate) * FEE_LEG_COUNT return premium_unit + fee_unit def compute_k( *, budget: float, index_px: float, option_ask: float, fee_rate: float, perp_unit: float = BASE_PERP_ETH, option_unit: float = BASE_OPTION_ETH, exit_unit: float = BASE_EXIT_USDT, ) -> RiskSizingResult: if budget is None or budget <= 0 or not math.isfinite(budget): return RiskSizingResult(ok=False, detail="以损定仓预算无效(须 > 0)") if index_px is None or index_px <= 0 or not math.isfinite(index_px): return RiskSizingResult(ok=False, detail="以损定仓缺少有效指数价") if option_ask is None or option_ask <= 0 or not math.isfinite(option_ask): return RiskSizingResult(ok=False, detail="以损定仓缺少有效期权卖一") if option_unit <= 0 or perp_unit <= 0 or exit_unit <= 0: return RiskSizingResult(ok=False, detail="以损定仓比例/出场基数须 > 0") cost1 = unit_cost( index_px=index_px, option_ask=option_ask, fee_rate=fee_rate, option_unit=option_unit, ) if cost1 <= 1e-12: return RiskSizingResult(ok=False, detail="以损定仓单位成本无效") k_raw = float(budget) / cost1 k = floor_k_1dp(k_raw) if k < MIN_K - 1e-12: return RiskSizingResult( ok=False, detail=( f"以损定仓算出 k={k_raw:.4f},向下取整后 < {MIN_K}," f"预算 {budget:.2f}U 不足以开最小仓(单位成本≈{cost1:.2f}U)" ), budget=_round2(float(budget)), k=k, index_px=float(index_px), option_ask=float(option_ask), perp_unit=float(perp_unit), option_unit=float(option_unit), exit_unit=float(exit_unit), ) # 若浮点导致仍略超,再降一档 while k >= MIN_K - 1e-12: prem = float(option_ask) * float(option_unit) * k fee = float(index_px) * float(fee_rate) * FEE_LEG_COUNT * k mx = prem + fee if mx <= float(budget) + 1e-6: return RiskSizingResult( ok=True, detail="ok", k=k, budget=_round2(float(budget)), premium_est=_round2(prem), fee_est=_round2(fee), max_loss=_round2(mx), perp_qty_eth=round(float(perp_unit) * k, 4), option_qty_eth=round(float(option_unit) * k, 4), net_profit_target=_round2(float(exit_unit) * k), index_px=float(index_px), option_ask=float(option_ask), perp_unit=float(perp_unit), option_unit=float(option_unit), exit_unit=float(exit_unit), ) k = round(k - 0.1, 1) return RiskSizingResult( ok=False, detail=f"以损定仓无法在预算 {budget:.2f}U 内找到合规 k", budget=_round2(float(budget)), index_px=float(index_px), option_ask=float(option_ask), perp_unit=float(perp_unit), option_unit=float(option_unit), exit_unit=float(exit_unit), ) def resolve_capital_base(db: Database | None = None) -> tuple[float | None, str]: """返回 (本金USDT口径, 说明)。""" database = db or get_db() ledger = Ledger(database) source = ( ledger.get_setting_str("risk_capital_source", "trading_account") or "trading_account" ).strip().lower() if source in ("manual", "manual_capital", "fixed"): cap = ledger.get_setting_float("risk_manual_capital_usdt", 0.0) if cap <= 0: return None, "单独本金未设置或 ≤ 0" return float(cap), "manual" # trading_account:交易账户 USDT + USDC(1:1 折算,与资金条交易账户一致) usdt, usdc = _trading_balances(database) if usdt is None and usdc is None: try: from ..exchange.runtime import load_runtime_settings ex = str(load_runtime_settings().exchange or "").strip().lower() if ex in ("binance", "bn") and not get_settings().is_sim: return ( None, "币安实盘暂未接入交易账户余额,请改用「单独本金」或「亏损值」", ) except Exception: pass return None, "无法读取交易账户资金" total = float(usdt or 0.0) + float(usdc or 0.0) if total <= 1e-9: return None, "交易账户总资金为 0" return total, "trading_account" def resolve_budget(db: Database | None = None) -> tuple[float | None, str, float | None]: """返回 (budget, detail, capital_base)。""" database = db or get_db() ledger = Ledger(database) loss_mode = ( ledger.get_setting_str("risk_loss_mode", "percent") or "percent" ).strip().lower() if loss_mode in ("absolute", "usdt", "value", "亏损值"): bud = ledger.get_setting_float("risk_loss_usdt", 0.0) if bud <= 0: return None, "亏损值未设置或 ≤ 0", None return float(bud), "absolute", None capital, src = resolve_capital_base(database) if capital is None: return None, src, None pct = ledger.get_setting_float("risk_loss_pct", 1.0) if pct <= 0: return None, "亏损幅度须 > 0", capital return float(capital) * (float(pct) / 100.0), f"percent@{src}", capital def _trading_balances(db: Database) -> tuple[float | None, float | None]: s = get_settings() if s.is_sim: from ..sim.funds_wallets import SimFundsWallets w = SimFundsWallets(db) v = w.view() return float(v["trading_usdt"]), float(v["trading_usdc"]) try: from ..exchange.runtime import load_runtime_settings ex = str(load_runtime_settings().exchange or "").strip().lower() if ex in ("binance", "bn"): return None, None from ..live.okx_funds import OkxFundsClient client = OkxFundsClient() try: bal = client.fetch_balances() tu = bal.get("trading_usdt") tc = bal.get("trading_usdc") return ( float(tu) if tu is not None else None, float(tc) if tc is not None else None, ) finally: client.close() except Exception as e: logger.warning("risk_sizing trading balance failed: %s", e) return None, None def compute_risk_sizing( *, index_px: float, option_ask: float, db: Database | None = None, ) -> RiskSizingResult: database = db or get_db() ledger = Ledger(database) s = get_settings() fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate) perp_u, opt_u, exit_u = read_risk_units(ledger) budget, bud_detail, capital = resolve_budget(database) if budget is None: return RiskSizingResult(ok=False, detail=f"以损定仓预算失败: {bud_detail}") r = compute_k( budget=budget, index_px=index_px, option_ask=option_ask, fee_rate=fee_rate, perp_unit=perp_u, option_unit=opt_u, exit_unit=exit_u, ) if not r.ok: return RiskSizingResult( ok=False, detail=r.detail, budget=_round2(budget), capital_base=_round2(capital) if capital is not None else None, index_px=float(index_px), option_ask=float(option_ask), k=r.k, perp_unit=perp_u, option_unit=opt_u, exit_unit=exit_u, ) return RiskSizingResult( ok=True, detail=r.detail, k=r.k, budget=_round2(budget), capital_base=_round2(capital) if capital is not None else None, premium_est=r.premium_est, fee_est=r.fee_est, max_loss=r.max_loss, perp_qty_eth=r.perp_qty_eth, option_qty_eth=r.option_qty_eth, net_profit_target=r.net_profit_target, index_px=r.index_px, option_ask=r.option_ask, perp_unit=perp_u, option_unit=opt_u, exit_unit=exit_u, ) def apply_risk_sizing_to_ledger( *, index_px: float, option_ask: float, db: Database | None = None, ) -> RiskSizingResult: """计算并写入 perp/option/exit;非以损定仓模式直接 ok 跳过。""" database = db or get_db() ledger = Ledger(database) if not is_risk_based(ledger): return RiskSizingResult(ok=True, detail="manual_sizing_skip") r = compute_risk_sizing(index_px=index_px, option_ask=option_ask, db=database) if not r.ok: return r # 以损定仓强制 fixed_usdt;出场 = exit_unit × k database.set_setting("exit_mode", "fixed_usdt") database.set_setting("perp_qty_eth", str(r.perp_qty_eth)) database.set_setting("option_qty_eth", str(r.option_qty_eth)) database.set_setting("net_profit_target", str(r.net_profit_target)) database.set_setting("risk_last_k", str(r.k)) database.set_setting( "risk_last_max_loss", f"{r.max_loss:.2f}" if r.max_loss is not None else "", ) logger.info( "risk_sizing applied k=%.1f perp=%.4f opt=%.4f exit=%.4f max_loss=%.4f budget=%.4f", r.k or 0, r.perp_qty_eth or 0, r.option_qty_eth or 0, r.net_profit_target or 0, r.max_loss or 0, r.budget or 0, ) return r def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]: """设置页预览:用当前盘口粗估。""" database = db or get_db() ledger = Ledger(database) out: dict[str, Any] = { "sizing_mode": ledger.get_setting_str("sizing_mode", "manual") or "manual", "risk_based": is_risk_based(ledger), } if not is_risk_based(ledger): out["ok"] = True out["detail"] = "当前为手动仓位" return out try: from .open_capacity import _index_and_option_ask idx, ask = _index_and_option_ask() except Exception: idx, ask = None, None if idx is None or ask is None: out["ok"] = False out["detail"] = "暂无指数或期权卖一,无法预览" return out r = compute_risk_sizing(index_px=float(idx), option_ask=float(ask), db=database) perp_u, opt_u, exit_u = read_risk_units(ledger) out.update( { "ok": r.ok, "detail": r.detail, "k": r.k, "budget": r.budget, "capital_base": r.capital_base, "premium_est": r.premium_est, "fee_est": r.fee_est, "max_loss": r.max_loss, "perp_qty_eth": r.perp_qty_eth, "option_qty_eth": r.option_qty_eth, "net_profit_target": r.net_profit_target, "index_px": r.index_px, "option_ask": r.option_ask, "perp_unit": perp_u, "option_unit": opt_u, "exit_unit": exit_u, } ) return out