Files
dekun b09d1b0886 Show semi risk-based open size from ask and unit ratio.
Semi sizing uses market ask with semi units; Plan panel previews option/perp qty under the form.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:27:19 +08:00

987 lines
34 KiB
Python
Raw Permalink 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.
"""以损定仓:按可承受最大亏损反推标准组倍数 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