"""对冲计划:情景测算与全仓建议仓(纯函数,无 IO).""" from __future__ import annotations from typing import Any, Optional def _f(v: Any) -> Optional[float]: if v is None or v == "": return None try: return float(v) except (TypeError, ValueError): return None def perp_coin_amount(*, contracts: float, contract_size: float) -> float: return float(contracts) * float(contract_size or 1.0) def perp_pnl( *, direction: str, entry: float, exit_px: float, contracts: float, contract_size: float, ) -> float: coins = perp_coin_amount(contracts=contracts, contract_size=contract_size) d = (direction or "long").strip().lower() if d == "short": return (float(entry) - float(exit_px)) * coins return (float(exit_px) - float(entry)) * coins def option_premium_total(*, ask: float, sheets: float, ct_mult: float) -> float: """卖一报价为每 1 币;权利金 = ask × 张数 × ct_mult.""" return float(ask) * float(sheets) * float(ct_mult or 0.01) def option_expiry_pnl( *, opt_type: str, strike: float, spot: float, sheets: float, ct_mult: float, premium_paid: float, ) -> float: o = (opt_type or "").strip().upper() intrinsic_per_coin = 0.0 if o in ("C", "CALL"): intrinsic_per_coin = max(0.0, float(spot) - float(strike)) elif o in ("P", "PUT"): intrinsic_per_coin = max(0.0, float(strike) - float(spot)) else: return -float(premium_paid) value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01) return value - float(premium_paid) def suggest_contracts_from_notional( *, notional: float, entry: float, contract_size: float, ) -> float: if entry <= 0 or contract_size <= 0 or notional <= 0: return 0.0 return float(notional) / (float(entry) * float(contract_size)) def floor_contracts_to_precision(contracts: float, decimals: int) -> float: """按交易所张数精度向下取整,避免建议张数超过可用保证金.""" import math raw = float(contracts or 0.0) if raw <= 0: return 0.0 try: d = int(decimals) except (TypeError, ValueError): d = 0 if d <= 0: return float(math.floor(raw + 1e-12)) scale = 10**d return math.floor(raw * scale + 1e-12) / scale def option_unit_cost_usdc(*, ask: float, ct_mult: float) -> float: """单张权利金(USDC) = 卖一价 × ct_mult.""" a = _f(ask) if a is None or a <= 0: return 0.0 return float(a) * float(ct_mult or 0.01) def resolve_oo_budget_usdc( *, trading_usdc: Any, trade_budget_usdc: Any, buffer_ratio: Any = 0.95, ) -> dict[str, Any]: """期期可用预算 = min(交易户×buffer, 单笔预算).""" import math trading = _f(trading_usdc) cap = _f(trade_budget_usdc) buf = _f(buffer_ratio) if buf is None or buf <= 0: buf = 0.95 if buf > 1: buf = 1.0 trading_cap = None if trading is None else max(0.0, float(trading) * float(buf)) trade_cap = None if cap is None else max(0.0, float(cap)) if trading_cap is None and trade_cap is None: return { "ok": False, "budget_usdc": 0.0, "trading_cap": None, "trade_budget_cap": None, "buffer_ratio": float(buf), "msg": "缺少交易户余额与单笔预算", } if trading_cap is None: budget = float(trade_cap or 0.0) elif trade_cap is None: budget = float(trading_cap) else: budget = min(float(trading_cap), float(trade_cap)) budget = float(math.floor(budget * 1e6 + 1e-12) / 1e6) return { "ok": budget > 0, "budget_usdc": budget, "trading_cap": None if trading_cap is None else round(float(trading_cap), 6), "trade_budget_cap": None if trade_cap is None else round(float(trade_cap), 6), "buffer_ratio": float(buf), "msg": "" if budget > 0 else "可用预算为 0", } def _cap_sheets_by_ask_depth(sheets: int, ask_sz: Any) -> int: import math n = max(0, int(sheets)) depth = _f(ask_sz) if depth is None: return n if depth <= 0: return 0 return min(n, int(math.floor(float(depth) + 1e-12))) def _normalize_oo_sheets_mode(mode: str) -> str: m = (mode or "same_sheets").strip().lower() if m in ("long_bias", "bias_long", "long", "做多"): return "long_bias" if m in ("short_bias", "bias_short", "short", "做空"): return "short_bias" # 旧「均分」兼容:按预算 50/50(页面已移除) if m in ("split", "equal_budget", "split_budget", "均分"): return "split_budget" return "same_sheets" def _normalize_oo_bias_split_by(raw: Any) -> str: v = str(raw or "budget").strip().lower() if v in ("sheets", "qty", "quantity", "张数"): return "sheets" return "budget" def _clamp_oo_bias_ratio(raw: Any, default: float = 0.7) -> float: try: r = float(raw) except (TypeError, ValueError): r = float(default) if r <= 0 or r >= 1: r = float(default) return r def _oo_call_put_leg_index(opt_type_a: str, opt_type_b: str) -> tuple[Optional[str], Optional[str], str]: """返回 (call_side, put_side, err);side 为 'a'/'b'.""" a = (opt_type_a or "").strip().upper() b = (opt_type_b or "").strip().upper() if a.startswith("C"): a = "C" elif a.startswith("P"): a = "P" if b.startswith("C"): b = "C" elif b.startswith("P"): b = "P" if {a, b} != {"C", "P"}: return None, None, "做多/做空需一腿 Call、一腿 Put" call_side = "a" if a == "C" else "b" put_side = "b" if call_side == "a" else "a" return call_side, put_side, "" def suggest_oo_sheets( *, mode: str, budget_usdc: float, ask_a: float, ct_mult_a: float = 0.01, ask_sz_a: Any = None, opt_type_a: str = "", ask_b: float, ct_mult_b: float = 0.01, ask_sz_b: Any = None, opt_type_b: str = "", bias_split_by: str = "budget", bias_ratio: float = 0.7, ) -> dict[str, Any]: """期期建议张数:same_sheets / long_bias / short_bias(及旧 split_budget).""" import math m = _normalize_oo_sheets_mode(mode) split_by = _normalize_oo_bias_split_by(bias_split_by) ratio = _clamp_oo_bias_ratio(bias_ratio) budget = max(0.0, float(budget_usdc or 0.0)) cost_a = option_unit_cost_usdc(ask=ask_a, ct_mult=ct_mult_a) cost_b = option_unit_cost_usdc(ask=ask_b, ct_mult=ct_mult_b) def _fail(msg: str, n_a: int = 0, n_b: int = 0) -> dict[str, Any]: return { "mode": m, "sheets_a": n_a, "sheets_b": n_b, "cost_a": round(cost_a, 8), "cost_b": round(cost_b, 8), "premium_est": round(cost_a * n_a + cost_b * n_b, 6), "ok": False, "msg": msg, "bias_split_by": split_by, "bias_ratio": ratio, } if budget <= 0: return _fail("可用预算为 0") if cost_a <= 0 or cost_b <= 0: return _fail("缺少有效卖一价,无法建议张数") pair = cost_a + cost_b n_pair = int(math.floor(budget / pair + 1e-12)) if pair > 0 else 0 # 与同张数一致:先按预算得 n,再各自深度封顶后取 min n_same = min( _cap_sheets_by_ask_depth(n_pair, ask_sz_a), _cap_sheets_by_ask_depth(n_pair, ask_sz_b), ) if m == "same_sheets": n_a = n_same n_b = n_same elif m == "split_budget": half = budget / 2.0 n_a = int(math.floor(half / cost_a + 1e-12)) n_b = int(math.floor(half / cost_b + 1e-12)) n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a) n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b) else: call_side, put_side, err = _oo_call_put_leg_index(opt_type_a, opt_type_b) if err: return _fail(err) major_is_call = m == "long_bias" if split_by == "sheets": # 总张数 = 同张数两侧合计(每腿 n → 共 2n),再按比例拆到 Call/Put total = int(n_same) * 2 if total < 2: return _fail("同张数总规模不足 2,无法按比例拆分") major_n = int(round(total * ratio)) major_n = max(1, min(major_n, total - 1)) minor_n = total - major_n n_call = major_n if major_is_call else minor_n n_put = minor_n if major_is_call else major_n else: maj_budget = budget * ratio min_budget = budget * (1.0 - ratio) cost_call = cost_a if call_side == "a" else cost_b cost_put = cost_b if call_side == "a" else cost_a if major_is_call: n_call = int(math.floor(maj_budget / cost_call + 1e-12)) if cost_call > 0 else 0 n_put = int(math.floor(min_budget / cost_put + 1e-12)) if cost_put > 0 else 0 else: n_put = int(math.floor(maj_budget / cost_put + 1e-12)) if cost_put > 0 else 0 n_call = int(math.floor(min_budget / cost_call + 1e-12)) if cost_call > 0 else 0 n_a = n_call if call_side == "a" else n_put n_b = n_put if call_side == "a" else n_call n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a) n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b) prem = cost_a * n_a + cost_b * n_b ok = n_a >= 1 and n_b >= 1 msg = "" if ok else "预算不够开 1+1(或卖一深度不足)" return { "mode": m, "sheets_a": n_a, "sheets_b": n_b, "cost_a": round(cost_a, 8), "cost_b": round(cost_b, 8), "premium_est": round(prem, 6), "ok": ok, "msg": msg, "bias_split_by": split_by, "bias_ratio": ratio, } def build_perp_options_preview( *, direction: str, entry: float, tp: float, sl: float, contracts: float, contract_size: float, opt_type: str, strike: float, sheets: float, ct_mult: float, premium_paid: float, index_px: Optional[float] = None, ) -> dict[str, Any]: """ 永期情景. 止盈账:永续止盈盈利 - 权利金. 止损账:期权到期内在(按 SL 价) - 永续止损亏损额. """ d = (direction or "long").strip().lower() pnl_tp_perp = perp_pnl( direction=d, entry=entry, exit_px=tp, contracts=contracts, contract_size=contract_size ) pnl_sl_perp = perp_pnl( direction=d, entry=entry, exit_px=sl, contracts=contracts, contract_size=contract_size ) # 止盈统计口径 tp_total = float(pnl_tp_perp) - float(premium_paid) # 止损:期权按 SL 价结算内在 - |永续亏损| opt_at_sl = option_expiry_pnl( opt_type=opt_type, strike=strike, spot=sl, sheets=sheets, ct_mult=ct_mult, premium_paid=premium_paid, ) sl_total = float(opt_at_sl) - abs(float(pnl_sl_perp)) if pnl_sl_perp < 0 else float(opt_at_sl) + float( pnl_sl_perp ) # 有符号相加更稳:期权盈亏 + 永续盈亏 sl_total_signed = float(opt_at_sl) + float(pnl_sl_perp) spot = float(index_px) if index_px is not None else float(entry) opt_flat = option_expiry_pnl( opt_type=opt_type, strike=strike, spot=spot, sheets=sheets, ct_mult=ct_mult, premium_paid=premium_paid, ) flat_total = 0.0 + float(opt_flat) opt_at_tp = option_expiry_pnl( opt_type=opt_type, strike=strike, spot=tp, sheets=sheets, ct_mult=ct_mult, premium_paid=premium_paid, ) return { "plan_type": "perp_options", "direction": d, "contracts": contracts, "coin_amount": perp_coin_amount(contracts=contracts, contract_size=contract_size), "premium_paid": round(float(premium_paid), 6), "scenarios": [ { "id": "tp", "label": "止盈(计划结束口径)", "spot": tp, "perp_pnl": round(pnl_tp_perp, 4), "options_pnl": round(-float(premium_paid), 4), "total": round(tp_total, 4), "note": "止盈盈利 − 权利金;期权可不强平", }, { "id": "sl", "label": "止损(计划结束口径)", "spot": sl, "perp_pnl": round(pnl_sl_perp, 4), "options_pnl": round(opt_at_sl, 4), "total": round(sl_total_signed, 4), "note": "期权盈利 − 永续亏损(有符号相加);期权须强平", }, { "id": "flat", "label": "到期·现价附近", "spot": spot, "perp_pnl": 0.0, "options_pnl": round(opt_flat, 4), "total": round(flat_total, 4), "note": "示意:永续未动,期权按到期内在", }, { "id": "expiry_tp", "label": "到期·止盈价", "spot": tp, "perp_pnl": round(pnl_tp_perp, 4), "options_pnl": round(opt_at_tp, 4), "total": round(pnl_tp_perp + opt_at_tp, 4), "note": "若期权拿到 TP 价到期(参考)", }, { "id": "expiry_sl", "label": "到期·止损价", "spot": sl, "perp_pnl": round(pnl_sl_perp, 4), "options_pnl": round(opt_at_sl, 4), "total": round(pnl_sl_perp + opt_at_sl, 4), "note": "与止损口径相近(期权用内在)", }, ], "summary": { "tp_total": round(tp_total, 4), "sl_total": round(sl_total_signed, 4), "premium_paid": round(float(premium_paid), 4), "hedge_ratio_at_sl": _hedge_ratio(opt_at_sl, pnl_sl_perp), }, } def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]: loss = abs(float(perp_pnl)) if float(perp_pnl) < 0 else 0.0 if loss <= 1e-12: return None if float(opt_pnl) <= 0: return 0.0 return round(float(opt_pnl) / loss * 100.0, 2) def build_options_options_preview( *, target_price: float | None = None, target_price_up: float | None = None, target_price_down: float | None = None, index_px: float, leg_a: dict[str, Any], leg_b: dict[str, Any], ) -> dict[str, Any]: """期期情景:上破/下破目标价 / 到期现价 / 最大保费损耗.""" def _leg_pnl(leg: dict[str, Any], spot: float) -> float: return option_expiry_pnl( opt_type=str(leg.get("opt_type") or ""), strike=float(leg["strike"]), spot=spot, sheets=float(leg.get("sheets") or 0), ct_mult=float(leg.get("ct_mult") or 0.01), premium_paid=float(leg.get("premium_paid") or 0), ) # 兼容旧单目标:若未传上下目标则用 target_price 填两边 up = target_price_up if target_price_up is not None else target_price down = target_price_down if target_price_down is not None else target_price if up is None or down is None: raise ValueError("缺少上破/下破目标价") up_f = float(up) down_f = float(down) prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0) a_up = _leg_pnl(leg_a, up_f) b_up = _leg_pnl(leg_b, up_f) at_up = a_up + b_up win_up = "a" if a_up >= b_up else "b" a_dn = _leg_pnl(leg_a, down_f) b_dn = _leg_pnl(leg_b, down_f) at_dn = a_dn + b_dn win_dn = "a" if a_dn >= b_dn else "b" a_flat = _leg_pnl(leg_a, index_px) b_flat = _leg_pnl(leg_b, index_px) flat_total = a_flat + b_flat expiry_loss = flat_total if flat_total <= 0 else flat_total return { "plan_type": "options_options", "premium_paid": round(prem, 6), "target_price": up_f, # 兼容旧字段,取上破 "target_price_up": up_f, "target_price_down": down_f, "winner_at_up": win_up, "winner_at_down": win_dn, "winner_at_target": win_up, "scenarios": [ { "id": "target_up", "label": "上破目标", "spot": up_f, "leg_a_pnl": round(a_up, 4), "leg_b_pnl": round(b_up, 4), "total": round(at_up, 4), "note": f"盈利方≈腿{win_up.upper()}(可平);亏损方默认到期", }, { "id": "target_down", "label": "下破目标", "spot": down_f, "leg_a_pnl": round(a_dn, 4), "leg_b_pnl": round(b_dn, 4), "total": round(at_dn, 4), "note": f"盈利方≈腿{win_dn.upper()}(可平);亏损方默认到期", }, { "id": "expiry_flat", "label": "到期·现价(无突破)", "spot": index_px, "leg_a_pnl": round(a_flat, 4), "leg_b_pnl": round(b_flat, 4), "total": round(flat_total, 4), "note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值", }, { "id": "max_premium_loss", "label": "最大保费损耗", "spot": None, "leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4), "leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4), "total": round(-prem, 4), "note": "双腿权利金全部损失", }, ], "summary": { "at_target_up_total": round(at_up, 4), "at_target_down_total": round(at_dn, 4), "at_target_total": round(at_up, 4), "expiry_flat_total": round(expiry_loss, 4), "premium_paid": round(prem, 6), "expiry_is_loss": flat_total <= 0, # 盈亏比:盈利/全亏保费(风险=权利金全损) "rr_risk_premium": round(prem, 6), "rr_at_up": round(at_up / prem, 4) if prem > 0 else None, "rr_at_down": round(at_dn / prem, 4) if prem > 0 else None, }, } def gate_status( *, hedge_enabled: bool, sizing_mode: str, plan_type: str, options_enabled: bool, live_order: bool = False, live_trading: bool = False, active_count: int = 0, max_active: int = 1, show_perp_options: bool = True, show_options_options: bool = True, mutual_exclusive: bool = True, has_standalone_option: bool = False, ) -> dict[str, Any]: from lib.trade.position_sizing_lib import is_full_margin_mode full = is_full_margin_mode(sizing_mode) pt = (plan_type or "").strip().lower() can_preview = True can_start = True reasons: list[str] = [] if not hedge_enabled: can_start = False reasons.append("对冲计划未启用(HEDGE_PLAN_ENABLED)") if not options_enabled: can_preview = False can_start = False reasons.append("期权模块未启用") if pt == "perp_options" and not show_perp_options: can_preview = False can_start = False reasons.append("永期对冲已隐藏(HEDGE_PLAN_SHOW_PERP_OPTIONS)") if pt == "options_options" and not show_options_options: can_preview = False can_start = False reasons.append("期期对冲已隐藏(HEDGE_PLAN_SHOW_OPTIONS_OPTIONS)") if not live_order: can_start = False reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)") if active_count >= max(1, int(max_active or 1)): can_start = False reasons.append(f"活跃计划已达上限({max_active})") if mutual_exclusive and has_standalone_option: can_start = False reasons.append("存在单独期权持仓,禁止启动对冲计划(互斥门控)") if pt == "perp_options": if not full: can_start = False reasons.append("永期开仓仅全仓模式可用(当前可测算)") if not live_trading: can_start = False reasons.append("未开启实盘(LIVE_TRADING_ENABLED)") elif pt == "options_options": pass else: can_start = False reasons.append("未知计划类型") if can_start: reasons = [] return { "hedge_enabled": hedge_enabled, "options_enabled": options_enabled, "sizing_mode": sizing_mode, "is_full_margin": full, "plan_type": pt, "live_order": live_order, "live_trading": live_trading, "active_count": active_count, "max_active": max_active, "show_perp_options": bool(show_perp_options), "show_options_options": bool(show_options_options), "mutual_exclusive": bool(mutual_exclusive), "has_standalone_option": bool(has_standalone_option), "can_preview": can_preview, "can_start": can_start, "reasons": reasons, }