"""以损定仓:按可承受最大亏损反推标准组倍数 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 # 定仓用权利金(可能为选约杠杆隐含) actual_option_ask: float | None = None # 盘口真实卖一 leverage_basis: str | None = None # actual | selection perp_unit: float | None = None option_unit: float | None = None exit_unit: float | None = None def normalize_risk_leverage_basis(raw: str | None, default: str = "selection") -> str: v = (raw or default or "selection").strip().lower() if v in ("selection", "min_option_leverage", "select", "选约", "选约杠杆"): return "selection" if v in ("actual", "market", "ask", "实际", "实际杠杆"): return "actual" return "selection" if default == "selection" else "actual" def resolve_sizing_option_ask( *, index_px: float, option_ask: float, leverage_basis: str, min_option_leverage: float, ) -> tuple[float, str]: """ 返回 (定仓用卖一, 口径 actual|selection)。 selection:隐含卖一 = 指数 / 选约杠杆;actual:用盘口卖一。 """ basis = normalize_risk_leverage_basis(leverage_basis, "selection") if basis == "selection": lev = float(min_option_leverage) if lev > 1e-12 and math.isfinite(lev) and index_px > 0: return float(index_px) / lev, "selection" # 选约杠杆无效时退回实际卖一,避免拒单 return float(option_ask), "actual" return float(option_ask), "actual" 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 mg = resolve_martingale(database, ledger=ledger, base_pct=float(pct)) effective = float(mg["effective_pct"]) detail = f"percent@{src}" if int(mg.get("doubles") or 0) > 0: detail += ( f"|mg×{int(2 ** int(mg['doubles']))}" f"(连亏{int(mg.get('loss_days') or 0)}天)" ) return float(capital) * (effective / 100.0), detail, capital MARTINGALE_MAX_BASE_PCT = 3.0 def _is_expiry_close_reason(reason: str | None) -> bool: r = str(reason or "").strip().lower() return r in ("expiry", "到期", "到期结算", "到期结算全平") def _martingale_day_pnl_contrib(realized_pnl: float, close_reason: str | None) -> float: """ 倍投连亏日口径:到期结算无论实际盈亏(含小盈利)一律按亏损计入; 其它平仓按真实 realized_pnl。 """ if _is_expiry_close_reason(close_reason): return -1.0 return float(realized_pnl or 0.0) def consecutive_loss_days(db: Database | None = None) -> int: """ 按上海日历「平仓日」汇总倍投口径盈亏,从最近有平仓的一天往前数连续亏损天数。 某日合计 < 0 计为亏损日;到期结算组无论盈亏均按亏损计入。 无平仓的日历日不计入、不打断(按有成交日序列)。 """ from collections import defaultdict from datetime import datetime, timezone from zoneinfo import ZoneInfo database = db or get_db() rows = database.fetchall( """SELECT realized_pnl, close_at_ms, close_reason FROM groups WHERE status='closed' AND close_at_ms IS NOT NULL ORDER BY close_at_ms ASC""" ) if not rows: return 0 sh = ZoneInfo("Asia/Shanghai") day_pnl: dict[str, float] = defaultdict(float) for r in rows: try: ms = int(r["close_at_ms"] or 0) except (TypeError, ValueError): continue if ms <= 0: continue day = ( datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc) .astimezone(sh) .strftime("%Y-%m-%d") ) day_pnl[day] += _martingale_day_pnl_contrib( float(r["realized_pnl"] or 0), r["close_reason"], ) if not day_pnl: return 0 streak = 0 for d in reversed(sorted(day_pnl.keys())): if float(day_pnl[d]) < 0: streak += 1 else: break return streak def resolve_martingale( db: Database | None = None, *, ledger: Ledger | None = None, base_pct: float | None = None, ) -> dict[str, Any]: """ 倍投状态:仅以损定仓 + 亏损幅度% + 开关开启 + 基础幅度≤3% 时生效。 doubles: 已翻倍次数(0=用基础幅度);effective_pct = base * 2^doubles。 """ database = db or get_db() led = ledger or Ledger(database) enabled = led.get_setting_bool("martingale_enabled", False) pct = ( float(base_pct) if base_pct is not None else float(led.get_setting_float("risk_loss_pct", 1.0)) ) start_after = int( round(led.get_setting_float("martingale_start_after_loss_days", 2.0)) ) max_doubles = int(round(led.get_setting_float("martingale_max_doubles", 3.0))) start_after = max(1, min(30, start_after)) max_doubles = max(1, min(10, max_doubles)) loss_days = consecutive_loss_days(database) out: dict[str, Any] = { "enabled": bool(enabled), "eligible": False, "blocked": "", "base_pct": round(pct, 4), "effective_pct": round(pct, 4), "doubles": 0, "loss_days": int(loss_days), "start_after_loss_days": start_after, "max_doubles": max_doubles, } if not enabled: out["blocked"] = "off" return out if not is_risk_based(led): out["blocked"] = "not_risk_based" return out loss_mode = ( led.get_setting_str("risk_loss_mode", "percent") or "percent" ).strip().lower() if loss_mode not in ("percent", "pct", "%", "幅度"): out["blocked"] = "not_percent_mode" return out if pct > MARTINGALE_MAX_BASE_PCT + 1e-12: out["blocked"] = f"base_pct>{MARTINGALE_MAX_BASE_PCT:g}" return out out["eligible"] = True doubles = 0 if loss_days >= start_after: doubles = min(int(loss_days - start_after + 1), max_doubles) out["doubles"] = doubles out["effective_pct"] = round(float(pct) * (2**doubles), 6) return out 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, perp_unit: float | None = None, option_unit: float | None = None, exit_unit: float | None = None, leverage_basis: str | 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) if perp_unit is not None and float(perp_unit) > 0: perp_u = float(perp_unit) if option_unit is not None and float(option_unit) > 0: opt_u = float(option_unit) if exit_unit is not None and float(exit_unit) > 0: exit_u = float(exit_unit) basis_raw = ( leverage_basis if leverage_basis is not None else ( ledger.get_setting_str("risk_leverage_basis", s.risk_leverage_basis) or s.risk_leverage_basis ) ) min_opt_lev = ledger.get_setting_float( "min_option_leverage", s.min_option_leverage ) sizing_ask, basis = resolve_sizing_option_ask( index_px=float(index_px), option_ask=float(option_ask), leverage_basis=str(basis_raw), min_option_leverage=float(min_opt_lev), ) budget, bud_detail, capital = resolve_budget(database) if budget is None: return RiskSizingResult( ok=False, detail=f"以损定仓预算失败: {bud_detail}", leverage_basis=basis, actual_option_ask=_round2(float(option_ask)), option_ask=_round2(float(sizing_ask)), ) r = compute_k( budget=budget, index_px=index_px, option_ask=sizing_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=_round2(float(sizing_ask)), actual_option_ask=_round2(float(option_ask)), leverage_basis=basis, 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=_round2(float(sizing_ask)), actual_option_ask=_round2(float(option_ask)), leverage_basis=basis, perp_unit=perp_u, option_unit=opt_u, exit_unit=exit_u, ) @dataclass(frozen=True, slots=True) class OoSizingResult: ok: bool detail: str budget: float | None = None spend: float | None = None qty_eth: float | None = None # 兼容:Call 数量 call_qty_eth: float | None = None put_qty_eth: float | None = None call_ask: float | None = None put_ask: float | None = None call_premium: float | None = None put_premium: float | None = None max_loss: float | None = None net_profit_target: float | None = None capital_base: float | None = None cushion: float | None = None reward_ratio: float | None = None leg_budget: float | None = None # 单腿权利金预算(B/2×cushion) def compute_oo_sizing( *, budget: float, call_ask: float, put_ask: float, fee_rate: float = 0.0005, index_px: float = 0.0, cushion: float = 0.92, reward_ratio: float = 2.0, ) -> OoSizingResult: """ 期期:总预算 B 平分给 Call/Put(各约 B/2,再乘 cushion 预留); 两腿按各自卖一独立定仓 qty=floor_1dp(腿预算/ask),数量可以不同; 出场目标 = B × reward_ratio(按全额预算)。 """ if budget is None or budget <= 0 or not math.isfinite(budget): return OoSizingResult(ok=False, detail="期期预算无效") if call_ask <= 0 or put_ask <= 0: return OoSizingResult(ok=False, detail="期期缺少有效卖一") cush = min(1.0, max(0.5, float(cushion))) ratio = max(0.5, float(reward_ratio)) # 各腿:总预算一半 × 预留 leg_raw = float(budget) / 2.0 leg_budget = leg_raw * cush # 单腿开仓费粗估(从该腿预算里扣) fee_one = 0.0 if index_px and index_px > 0 and fee_rate > 0: fee_one = float(index_px) * float(fee_rate) leg_spend = max(0.0, leg_budget - fee_one) if leg_spend <= 1e-9: return OoSizingResult(ok=False, detail="期期单腿预留后可用权利金不足") def _leg_qty(ask: float) -> tuple[float, float]: q = floor_k_1dp(leg_spend / float(ask)) while q >= 0.1 - 1e-12: prem = float(ask) * q if prem <= leg_spend + 1e-6: return round(q, 1), prem q = round(q - 0.1, 1) return 0.0, 0.0 q_call, cp = _leg_qty(float(call_ask)) q_put, pp = _leg_qty(float(put_ask)) if q_call < 0.1 - 1e-12 or q_put < 0.1 - 1e-12: return OoSizingResult( ok=False, detail=( f"期期定仓失败:Call可{q_call} Put可{q_put}(各腿预算约" f"{leg_budget:.2f}U),总预算 {budget:.2f}U 不足" ), budget=_round2(float(budget)), leg_budget=_round2(leg_budget), ) spend = leg_budget * 2.0 return OoSizingResult( ok=True, detail="ok", budget=_round2(float(budget)), spend=_round2(spend), qty_eth=round(q_call, 1), call_qty_eth=round(q_call, 1), put_qty_eth=round(q_put, 1), call_ask=_round2(float(call_ask)), put_ask=_round2(float(put_ask)), call_premium=_round2(cp), put_premium=_round2(pp), max_loss=_round2(cp + pp + fee_one * 2.0), net_profit_target=_round2(float(budget) * ratio), cushion=cush, reward_ratio=ratio, leg_budget=_round2(leg_budget), ) def apply_oo_sizing_to_ledger( *, call_ask: float, put_ask: float, index_px: float, db: Database | None = None, ) -> OoSizingResult: database = db or get_db() ledger = Ledger(database) s = get_settings() pos = database.fetchone("SELECT status FROM positions WHERE id=1") if pos is not None: st = str(pos["status"] or "flat") if st in ("open", "half_open", "option_closed_perp_pending", "opening"): return OoSizingResult( ok=False, detail="持仓中已锁定本组成交目标与名义,平仓后再自动计算", ) budget, detail, capital = resolve_budget(database) if budget is None: return OoSizingResult(ok=False, detail=f"期期预算失败: {detail}") fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate) cushion = ledger.get_setting_float("oo_budget_cushion", s.oo_budget_cushion) ratio = ledger.get_setting_float("oo_reward_ratio", s.oo_reward_ratio) r = compute_oo_sizing( budget=float(budget), call_ask=float(call_ask), put_ask=float(put_ask), fee_rate=fee_rate, index_px=float(index_px), cushion=cushion, reward_ratio=ratio, ) if not r.ok: return r call_q = float(r.call_qty_eth or r.qty_eth or 0) put_q = float(r.put_qty_eth or r.qty_eth or 0) database.set_setting("exit_mode", "fixed_usdt") database.set_setting("perp_qty_eth", "0") database.set_setting("option_qty_eth", str(call_q)) database.set_setting("oo_put_qty_eth", str(put_q)) database.set_setting("net_profit_target", str(r.net_profit_target)) database.set_setting("risk_last_k", str(call_q)) database.set_setting( "risk_last_max_loss", f"{r.max_loss:.2f}" if r.max_loss is not None else "", ) logger.info( "oo_sizing applied call_qty=%.1f put_qty=%.1f call_ask=%.4f put_ask=%.4f " "exit=%.2f max_loss=%.2f budget=%.2f leg=%.2f", call_q, put_q, r.call_ask or 0, r.put_ask or 0, r.net_profit_target or 0, r.max_loss or 0, r.budget or 0, r.leg_budget or 0, ) # attach capital for callers return OoSizingResult( ok=True, detail=r.detail, budget=r.budget, spend=r.spend, qty_eth=call_q, call_qty_eth=call_q, put_qty_eth=put_q, call_ask=r.call_ask, put_ask=r.put_ask, call_premium=r.call_premium, put_premium=r.put_premium, max_loss=r.max_loss, net_profit_target=r.net_profit_target, capital_base=_round2(capital) if capital is not None else None, cushion=r.cushion, reward_ratio=r.reward_ratio, leg_budget=r.leg_budget, ) def apply_risk_sizing_to_ledger( *, index_px: float, option_ask: float, db: Database | None = None, perp_unit: float | None = None, option_unit: float | None = None, exit_unit: float | None = None, leverage_basis: str | 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") # 有活跃仓:本组成场参数已锁定,禁止重算覆盖 pos = database.fetchone("SELECT status, group_id FROM positions WHERE id=1") if pos is not None: st = str(pos["status"] or "flat") if st in ("open", "half_open", "option_closed_perp_pending", "opening"): return RiskSizingResult( ok=False, detail="持仓中已锁定本组成交目标与名义,平仓后再自动计算", ) r = compute_risk_sizing( index_px=index_px, option_ask=option_ask, db=database, perp_unit=perp_unit, option_unit=option_unit, exit_unit=exit_unit, leverage_basis=leverage_basis, ) if not r.ok: return r exit_mode = str( ledger.get_setting_str("exit_mode", "fixed_usdt") or "fixed_usdt" ).strip().lower() s = get_settings() prem_mult = float( ledger.get_setting_float("premium_exit_multiple", s.premium_exit_multiple) or s.premium_exit_multiple ) 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("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 "", ) if exit_mode == "premium_multiple": database.set_setting("exit_mode", "premium_multiple") # 预估展示用:估权利金×倍数;开仓后以真实 initial_premium 锁定 est = float(r.premium_est or 0) * max(0.0, prem_mult) if est > 0: database.set_setting("net_profit_target", f"{est:.4f}") exit_log = f"prem×{prem_mult:g}≈{est:.2f}" else: database.set_setting("exit_mode", "fixed_usdt") database.set_setting("net_profit_target", str(r.net_profit_target)) exit_log = f"{r.net_profit_target or 0:.4f}" logger.info( "risk_sizing applied k=%.1f basis=%s sizing_ask=%.4f actual_ask=%.4f " "perp=%.4f opt=%.4f exit=%s max_loss=%.4f budget=%.4f", r.k or 0, r.leverage_basis or "?", r.option_ask or 0, r.actual_option_ask or 0, r.perp_qty_eth or 0, r.option_qty_eth or 0, exit_log, r.max_loss or 0, r.budget or 0, ) return r def _hedge_mode(ledger: Ledger | None = None) -> str: led = ledger or Ledger() s = get_settings() raw = str(led.get_setting_str("hedge_mode", s.hedge_mode) or s.hedge_mode).strip().lower() return raw if raw in ("perp_option", "option_option") else "perp_option" 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), "hedge_mode": _hedge_mode(ledger), } if not is_risk_based(ledger): out["ok"] = True out["detail"] = "当前为手动仓位" return out if out["hedge_mode"] == "option_option": return _preview_oo_sizing(database, ledger, 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 # 半自动:用本单配比/净利基数,并以盘口卖一定仓(与开仓一致) semi_perp = semi_opt = semi_exit = None semi_on = False try: from .semi_auto import is_semi_auto, read_semi_params if is_semi_auto(ledger): sp = read_semi_params(ledger) semi_on = True semi_perp = float(sp["perp_unit"]) semi_opt = float(sp["option_unit"]) semi_exit = float(sp["perp_exit_unit"]) except Exception: logger.debug("preview semi units skipped", exc_info=True) r = compute_risk_sizing( index_px=float(idx), option_ask=float(ask), db=database, perp_unit=semi_perp, option_unit=semi_opt, exit_unit=semi_exit, leverage_basis="actual" if semi_on else None, ) perp_u, opt_u, exit_u = read_risk_units(ledger) if semi_perp is not None: perp_u = float(semi_perp) if semi_opt is not None: opt_u = float(semi_opt) if semi_exit is not None: exit_u = float(semi_exit) mg = resolve_martingale(database, ledger=ledger) s = get_settings() exit_mode = str( ledger.get_setting_str("exit_mode", s.exit_mode) or s.exit_mode ).strip().lower() prem_mult = float( ledger.get_setting_float("premium_exit_multiple", s.premium_exit_multiple) or s.premium_exit_multiple ) exit_target = r.net_profit_target exit_label = "基数×k" if semi_on: exit_label = "半自动净利基数×k" exit_mode = "fixed_usdt" elif exit_mode == "premium_multiple": exit_label = f"权利金×{prem_mult:g}" if r.ok and r.premium_est is not None: exit_target = round(float(r.premium_est) * max(0.0, prem_mult), 2) 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": exit_target, "exit_mode": exit_mode if exit_mode in ("fixed_usdt", "premium_multiple") else "fixed_usdt", "premium_exit_multiple": prem_mult, "exit_label": exit_label, "index_px": r.index_px, "option_ask": r.option_ask, "actual_option_ask": r.actual_option_ask, "leverage_basis": r.leverage_basis, "perp_unit": perp_u, "option_unit": opt_u, "exit_unit": exit_u, "semi_units": semi_on, "martingale": mg, "risk_effective_loss_pct": mg.get("effective_pct"), } ) return out def _preview_oo_sizing( database: Database, ledger: Ledger, out: dict[str, Any] ) -> dict[str, Any]: """期期预览:出场目标 = 预算 × 盈亏比;有卖一时再估单腿 qty。""" s = get_settings() budget, detail, capital = resolve_budget(database) mg = resolve_martingale(database, ledger=ledger) ratio = float( ledger.get_setting_float("oo_reward_ratio", s.oo_reward_ratio) or s.oo_reward_ratio ) cush = float( ledger.get_setting_float("oo_budget_cushion", s.oo_budget_cushion) or s.oo_budget_cushion ) out["martingale"] = mg out["risk_effective_loss_pct"] = mg.get("effective_pct") out["reward_ratio"] = ratio out["cushion"] = cush if budget is None: out["ok"] = False out["detail"] = f"期期预算失败: {detail}" return out exit_target = _round2(float(budget) * max(0.5, ratio)) out.update( { "budget": _round2(float(budget)), "capital_base": _round2(float(capital)) if capital is not None else None, "net_profit_target": exit_target, "k": None, "perp_qty_eth": 0.0, } ) call_ask = put_ask = idx = None try: from .session import get_session snap = get_session().snapshot() idx = snap.index_px if snap.call and snap.call.ask and float(snap.call.ask) > 0: call_ask = float(snap.call.ask) if snap.put and snap.put.ask and float(snap.put.ask) > 0: put_ask = float(snap.put.ask) if idx is None and snap.perp and snap.perp.mark_px: idx = float(snap.perp.mark_px) except Exception: pass if call_ask is None or put_ask is None: try: from .open_capacity import _index_and_option_ask i2, a2 = _index_and_option_ask() if idx is None: idx = i2 # 回退:单腿 ATM 卖一不够准确,但至少能估数量量级 if call_ask is None and a2 is not None and float(a2) > 0: call_ask = float(a2) if put_ask is None and a2 is not None and float(a2) > 0: put_ask = float(a2) except Exception: pass if call_ask is None or put_ask is None or call_ask <= 0 or put_ask <= 0: out["ok"] = True out["detail"] = "已估出场目标;虚值双腿卖一未齐,数量待开仓时再算" out["option_qty_eth"] = None out["sizing_ok"] = False return out fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate) r = compute_oo_sizing( budget=float(budget), call_ask=float(call_ask), put_ask=float(put_ask), fee_rate=fee_rate, index_px=float(idx or 0), cushion=cush, reward_ratio=ratio, ) # 出场始终按全额预算×盈亏比;数量估失败仍返回 ok 以便 Plan 展示目标 out.update( { "ok": True, "sizing_ok": bool(r.ok), "detail": "ok" if r.ok else str(r.detail or "期期数量未估出"), "option_qty_eth": r.qty_eth if r.ok else None, "call_qty_eth": r.call_qty_eth if r.ok else None, "put_qty_eth": r.put_qty_eth if r.ok else None, "leg_budget": r.leg_budget if r.ok else None, "call_ask": r.call_ask, "put_ask": r.put_ask, "call_premium": r.call_premium if r.ok else None, "put_premium": r.put_premium if r.ok else None, "max_loss": r.max_loss if r.ok else None, "net_profit_target": exit_target, "index_px": _round2(float(idx)) if idx is not None else None, } ) return out