Polish risk sizing: 2dp money preview, editable ratio/exit, plan badge.

Exit unit and perp/option units scale with k; plan page shows 以损定仓.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-29 23:48:34 +08:00
parent 3a4c8d639c
commit a47a9b6377
8 changed files with 349 additions and 38 deletions
+9
View File
@@ -54,6 +54,9 @@ KEYS = (
"risk_loss_usdt",
"risk_capital_source",
"risk_manual_capital_usdt",
"risk_perp_unit",
"risk_option_unit",
"risk_exit_unit",
)
@@ -88,6 +91,9 @@ class StrategySettingsBody(BaseModel):
default=None, pattern="^(trading_account|manual)$"
)
risk_manual_capital_usdt: float | None = Field(default=None, ge=1, le=100_000_000)
risk_perp_unit: float | None = Field(default=None, ge=0.01, le=100)
risk_option_unit: float | None = Field(default=None, ge=0.01, le=100)
risk_exit_unit: float | None = Field(default=None, ge=0.1, le=1_000_000)
def _as_bool(raw: str | None, default: bool) -> bool:
@@ -244,6 +250,9 @@ def _read_settings() -> dict:
"risk_manual_capital_usdt": float(
db.get_setting("risk_manual_capital_usdt", "10000") or 10000
),
"risk_perp_unit": float(db.get_setting("risk_perp_unit", "1") or 1),
"risk_option_unit": float(db.get_setting("risk_option_unit", "2") or 2),
"risk_exit_unit": float(db.get_setting("risk_exit_unit", "15") or 15),
"risk_sizing_preview": _risk_preview_safe(),
"exchange": rt.exchange,
"perp_inst_id": rt.perp_inst_id,
+22
View File
@@ -112,6 +112,22 @@ class StrategyEngine:
fixed_perp = "long"
perp_qty = self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth)
opt_qty = self.ledger.get_setting_float("option_qty_eth", s.option_qty_eth)
sizing_mode = (
sm
if (
sm := str(
self.ledger.get_setting_str("sizing_mode", "manual") or "manual"
)
.strip()
.lower()
)
in ("manual", "risk_based")
else "manual"
)
risk_perp_unit = self.ledger.get_setting_float("risk_perp_unit", 1.0)
risk_option_unit = self.ledger.get_setting_float("risk_option_unit", 2.0)
risk_exit_unit = self.ledger.get_setting_float("risk_exit_unit", 15.0)
risk_last_k = self.ledger.get_setting_float("risk_last_k", 0.0)
rest_until = row["rest_until_ms"]
rest_left = 0
if rest_until:
@@ -150,6 +166,12 @@ class StrategyEngine:
"perp_margin_mode": perp_mm,
"perp_qty_eth": perp_qty,
"option_qty_eth": opt_qty,
"sizing_mode": sizing_mode,
"risk_based": sizing_mode == "risk_based",
"risk_perp_unit": risk_perp_unit,
"risk_option_unit": risk_option_unit,
"risk_exit_unit": risk_exit_unit,
"risk_last_k": risk_last_k if risk_last_k > 0 else None,
"min_option_hours": min_hours,
"min_option_leverage": min_opt_lev,
"atm_open_offset_enabled": atm_off_on,
+83 -21
View File
@@ -13,7 +13,7 @@ from ..sim.ledger import Ledger
logger = logging.getLogger(__name__)
# 标准组基准(k=1
# 标准组基准默认k=1;可由设置 risk_*_unit 覆盖
BASE_PERP_ETH = 1.0
BASE_OPTION_ETH = 2.0
BASE_EXIT_USDT = 15.0
@@ -21,6 +21,26 @@ 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
@@ -36,6 +56,9 @@ class RiskSizingResult:
net_profit_target: float | None = None
index_px: float | None = None
option_ask: float | None = None
perp_unit: float | None = None
option_unit: float | None = None
exit_unit: float | None = None
def is_risk_based(ledger: Ledger | None = None) -> bool:
@@ -51,9 +74,15 @@ def floor_k_1dp(k_raw: float) -> float:
return math.floor(k_raw * 10.0 + 1e-12) / 10.0
def unit_cost(*, index_px: float, option_ask: float, fee_rate: float) -> float:
"""k=1 时估算最大亏损 = 权利金(2ETH) + 手续费粗估。"""
premium_unit = float(option_ask) * BASE_OPTION_ETH
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
@@ -64,6 +93,9 @@ def compute_k(
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")
@@ -71,8 +103,15 @@ def compute_k(
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)
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="以损定仓单位成本无效")
@@ -85,15 +124,18 @@ def compute_k(
f"以损定仓算出 k={k_raw:.4f},向下取整后 < {MIN_K}"
f"预算 {budget:.2f}U 不足以开最小仓(单位成本≈{cost1:.2f}U"
),
budget=float(budget),
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) * BASE_OPTION_ETH * k
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:
@@ -101,24 +143,30 @@ def compute_k(
ok=True,
detail="ok",
k=k,
budget=float(budget),
premium_est=prem,
fee_est=fee,
max_loss=mx,
perp_qty_eth=round(BASE_PERP_ETH * k, 4),
option_qty_eth=round(BASE_OPTION_ETH * k, 4),
net_profit_target=round(BASE_EXIT_USDT * k, 4),
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=float(budget),
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),
)
@@ -220,6 +268,7 @@ def compute_risk_sizing(
ledger = Ledger(database)
s = get_settings()
fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate)
perp_u, opt_u, exit_u = read_risk_units(ledger)
budget, bud_detail, capital = resolve_budget(database)
if budget is None:
return RiskSizingResult(ok=False, detail=f"以损定仓预算失败: {bud_detail}")
@@ -228,23 +277,29 @@ def compute_risk_sizing(
index_px=index_px,
option_ask=option_ask,
fee_rate=fee_rate,
perp_unit=perp_u,
option_unit=opt_u,
exit_unit=exit_u,
)
if not r.ok:
return RiskSizingResult(
ok=False,
detail=r.detail,
budget=budget,
capital_base=capital,
budget=_round2(budget),
capital_base=_round2(capital) if capital is not None else None,
index_px=float(index_px),
option_ask=float(option_ask),
k=r.k,
perp_unit=perp_u,
option_unit=opt_u,
exit_unit=exit_u,
)
return RiskSizingResult(
ok=True,
detail=r.detail,
k=r.k,
budget=budget,
capital_base=capital,
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,
@@ -253,6 +308,9 @@ def compute_risk_sizing(
net_profit_target=r.net_profit_target,
index_px=r.index_px,
option_ask=r.option_ask,
perp_unit=perp_u,
option_unit=opt_u,
exit_unit=exit_u,
)
@@ -272,7 +330,7 @@ def apply_risk_sizing_to_ledger(
if not r.ok:
return r
# 以损定仓强制 fixed_usdt,保证出场 15×k
# 以损定仓强制 fixed_usdt;出场 = exit_unit × k
database.set_setting("exit_mode", "fixed_usdt")
database.set_setting("perp_qty_eth", str(r.perp_qty_eth))
database.set_setting("option_qty_eth", str(r.option_qty_eth))
@@ -280,7 +338,7 @@ def apply_risk_sizing_to_ledger(
database.set_setting("risk_last_k", str(r.k))
database.set_setting(
"risk_last_max_loss",
f"{r.max_loss:.6f}" if r.max_loss is not None else "",
f"{r.max_loss:.2f}" if r.max_loss is not None else "",
)
logger.info(
"risk_sizing applied k=%.1f perp=%.4f opt=%.4f exit=%.4f max_loss=%.4f budget=%.4f",
@@ -317,6 +375,7 @@ def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]:
out["detail"] = "暂无指数或期权卖一,无法预览"
return out
r = compute_risk_sizing(index_px=float(idx), option_ask=float(ask), db=database)
perp_u, opt_u, exit_u = read_risk_units(ledger)
out.update(
{
"ok": r.ok,
@@ -332,6 +391,9 @@ def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]:
"net_profit_target": r.net_profit_target,
"index_px": r.index_px,
"option_ask": r.option_ask,
"perp_unit": perp_u,
"option_unit": opt_u,
"exit_unit": exit_u,
}
)
return out
+27
View File
@@ -29,6 +29,33 @@ def test_compute_k_scales_1_2_15() -> None:
assert r.option_qty_eth == BASE_OPTION_ETH
assert r.net_profit_target == BASE_EXIT_USDT
assert r.max_loss is not None and r.max_loss <= 43.0 + 1e-6
assert r.budget == 43.0
def test_compute_k_custom_units() -> None:
# option_unit=4 → premium unit = 20*4=80; fee=3; cost=83; budget=83 → k=1
r = compute_k(
budget=83.0,
index_px=2000.0,
option_ask=20.0,
fee_rate=0.0005,
perp_unit=0.5,
option_unit=4.0,
exit_unit=30.0,
)
assert r.ok
assert r.k == 1.0
assert r.perp_qty_eth == 0.5
assert r.option_qty_eth == 4.0
assert r.net_profit_target == 30.0
def test_money_rounds_2dp() -> None:
r = compute_k(budget=50.123456, index_px=1900.0, option_ask=18.5, fee_rate=0.0005)
assert r.ok
assert r.budget == round(50.123456, 2)
assert r.max_loss is not None
assert abs(r.max_loss * 100 - round(r.max_loss * 100)) < 1e-9
def test_compute_k_never_exceeds_budget() -> None: