b09d1b0886
Semi sizing uses market ask with semi units; Plan panel previews option/perp qty under the form. Co-authored-by: Cursor <cursoragent@cursor.com>
987 lines
34 KiB
Python
987 lines
34 KiB
Python
"""以损定仓:按可承受最大亏损反推标准组倍数 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
|