From a47a9b637792c56111e62ca614dbe3cdc701eff0 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 29 Jul 2026 23:48:34 +0800 Subject: [PATCH] Polish risk sizing: 2dp money preview, editable ratio/exit, plan badge. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit unit and perp/option units scale with k; plan page shows 以损定仓. Co-authored-by: Cursor --- backend/app/api/settings.py | 9 ++ backend/app/strategy/engine.py | 22 ++++ backend/app/strategy/risk_sizing.py | 104 ++++++++++++--- backend/tests/test_risk_sizing.py | 27 ++++ docs/更新说明.md | 10 ++ frontend/src/api/client.ts | 3 + frontend/src/pages/Plan.tsx | 20 ++- frontend/src/pages/Settings.tsx | 192 ++++++++++++++++++++++++++-- 8 files changed, 349 insertions(+), 38 deletions(-) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index a95c62d..c263147 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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, diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index fd6b169..46fcaa4 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -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, diff --git a/backend/app/strategy/risk_sizing.py b/backend/app/strategy/risk_sizing.py index 9caa6e0..9da10d1 100644 --- a/backend/app/strategy/risk_sizing.py +++ b/backend/app/strategy/risk_sizing.py @@ -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 diff --git a/backend/tests/test_risk_sizing.py b/backend/tests/test_risk_sizing.py index aef8be6..c5ccec7 100644 --- a/backend/tests/test_risk_sizing.py +++ b/backend/tests/test_risk_sizing.py @@ -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: diff --git a/docs/更新说明.md b/docs/更新说明.md index 3325cb5..6758460 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,16 @@ --- +## 2026-07-29 — 以损定仓:预览两位小数 / 比例与出场可配 / 计划页标识 + +### 变更 + +1. 预览「预算」「估亏」统一保留两位小数。 +2. 以损定仓可设永续/期权比例与出场基数(k=1);改出场后按比例联动名义。 +3. 对冲计划选约条件显示「以损定仓」及比例。 + +--- + ## 2026-07-29 — 以损定仓(智能调仓) ### 变更 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 51b8ce0..9a3b9f2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -329,6 +329,9 @@ export type StrategySettings = { risk_loss_usdt?: number; risk_capital_source?: "trading_account" | "manual"; risk_manual_capital_usdt?: number; + risk_perp_unit?: number; + risk_option_unit?: number; + risk_exit_unit?: number; risk_sizing_preview?: Record; exchange?: string; }; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 12fc910..d9e8090 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -151,7 +151,9 @@ export default function PlanPage() { const exitRuleLabel = exitMode === "premium_multiple" ? `权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}` - : `固定 ${fmt(plan?.net_profit_target ?? 15)} U`; + : plan?.risk_based || plan?.sizing_mode === "risk_based" + ? `以损定仓 · 固定 ${fmt(exitTarget)} U(基数${fmt(plan?.risk_exit_unit ?? 15)})` + : `固定 ${fmt(plan?.net_profit_target ?? 15)} U`; const phaseLabel = PHASE_ZH[plan?.phase || ""] || plan?.phase || "—"; const atmRule = plan?.fixed_direction_enabled ? plan?.fixed_perp_side === "short" @@ -343,8 +345,20 @@ export default function PlanPage() {
选约条件 - 永续{fmt(plan?.perp_qty_eth ?? 1, 0)}ETH/期权 - {fmt(plan?.option_qty_eth ?? 2, 0)}ETH · 剩余≥ + {plan?.risk_based || plan?.sizing_mode === "risk_based" ? ( + <> + 以损定仓 + {" · "} + 比例{fmt(plan?.risk_perp_unit ?? 1, 2)}: + {fmt(plan?.risk_option_unit ?? 2, 2)} + {plan?.risk_last_k + ? ` · k=${fmt(plan.risk_last_k, 1)}` + : ""} + {" · "} + + ) : null} + 永续{fmt(plan?.perp_qty_eth ?? 1, 2)}ETH/期权 + {fmt(plan?.option_qty_eth ?? 2, 2)}ETH · 剩余≥ {fmt(plan?.min_option_hours, 0)}h · 期权杠杆≥ {fmt(plan?.min_option_leverage, 0)}x {plan?.fixed_direction_enabled diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index ab5bc9f..93b1a9b 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -94,6 +94,9 @@ export default function SettingsPage() { "trading_account" | "manual" >("trading_account"); const [riskManualCapital, setRiskManualCapital] = useState(10000); + const [riskPerpUnit, setRiskPerpUnit] = useState(1); + const [riskOptUnit, setRiskOptUnit] = useState(2); + const [riskExitUnit, setRiskExitUnit] = useState(15); const [riskPreview, setRiskPreview] = useState | null>( null, ); @@ -194,6 +197,9 @@ export default function SettingsPage() { s.risk_capital_source === "manual" ? "manual" : "trading_account", ); setRiskManualCapital(s.risk_manual_capital_usdt ?? 10000); + setRiskPerpUnit(s.risk_perp_unit ?? 1); + setRiskOptUnit(s.risk_option_unit ?? 2); + setRiskExitUnit(s.risk_exit_unit ?? 15); setRiskPreview( s.risk_sizing_preview && typeof s.risk_sizing_preview === "object" ? s.risk_sizing_preview @@ -349,6 +355,9 @@ export default function SettingsPage() { risk_loss_usdt: riskLossUsdt, risk_capital_source: riskCapitalSource, risk_manual_capital_usdt: riskManualCapital, + risk_perp_unit: riskPerpUnit, + risk_option_unit: riskOptUnit, + risk_exit_unit: riskExitUnit, exchange, }; // 以损定仓不提交手填名义/出场,避免禁用输入框脏值导致 422 @@ -739,17 +748,138 @@ export default function SettingsPage() {
- {riskPreview == null - ? "—" - : riskPreview.ok === false - ? String(riskPreview.detail || "预览不可用") - : `k=${String(riskPreview.k ?? "—")} · 预算=${String(riskPreview.budget ?? "—")}U · 估亏=${String(riskPreview.max_loss ?? "—")}U · 永续=${String(riskPreview.perp_qty_eth ?? "—")} · 期权=${String(riskPreview.option_qty_eth ?? "—")} · 出场=${String(riskPreview.net_profit_target ?? "—")}`} + {(() => { + if (riskPreview == null) return "—"; + if (riskPreview.ok === false) { + return String(riskPreview.detail || "预览不可用"); + } + const k = Number(riskPreview.k); + const bud = Number(riskPreview.budget); + const mx = Number(riskPreview.max_loss); + const pk = Number.isFinite(k) ? k : null; + const perp = + pk != null + ? Number((riskPerpUnit * pk).toFixed(4)) + : riskPreview.perp_qty_eth; + const opt = + pk != null + ? Number((riskOptUnit * pk).toFixed(4)) + : riskPreview.option_qty_eth; + const exit = + pk != null + ? Number((riskExitUnit * pk).toFixed(2)) + : riskPreview.net_profit_target; + const budS = Number.isFinite(bud) + ? bud.toFixed(2) + : "—"; + const mxS = Number.isFinite(mx) ? mx.toFixed(2) : "—"; + return `k=${pk ?? "—"} · 预算=${budS}U · 估亏=${mxS}U · 永续=${perp ?? "—"} · 期权=${opt ?? "—"} · 出场=${exit ?? "—"}`; + })()}
+
+ + { + const v = Number(e.target.value); + setRiskPerpUnit(v); + const k = Number(riskPreview?.k); + if (Number.isFinite(k) && k > 0) { + setPerpQty(Number((v * k).toFixed(4))); + } + }} + /> +
+
+ + { + const v = Number(e.target.value); + setRiskOptUnit(v); + const k = Number(riskPreview?.k); + if (Number.isFinite(k) && k > 0) { + setOptQty(Number((v * k).toFixed(4))); + } + }} + /> +
+
+ + { + const v = Number(e.target.value); + setRiskExitUnit(v); + const k = Number(riskPreview?.k); + if (Number.isFinite(k) && k > 0) { + setNetTarget(Number((v * k).toFixed(2))); + setPerpQty(Number((riskPerpUnit * k).toFixed(4))); + setOptQty(Number((riskOptUnit * k).toFixed(4))); + } + }} + /> +
+
+ + { + const k = Number(riskPreview?.k); + if (Number.isFinite(k) && k > 0) { + return Number((riskExitUnit * k).toFixed(2)); + } + return Number(riskExitUnit.toFixed(2)); + })() + } + onChange={(e) => { + const abs = Number(e.target.value); + const k = Number(riskPreview?.k); + if (Number.isFinite(k) && k > 0) { + const unit = Number((abs / k).toFixed(4)); + setRiskExitUnit(unit); + setNetTarget(Number(abs.toFixed(2))); + setPerpQty(Number((riskPerpUnit * k).toFixed(4))); + setOptQty(Number((riskOptUnit * k).toFixed(4))); + } else { + setRiskExitUnit(abs); + setNetTarget(abs); + } + }} + /> +
) : null}
- + 0 + ? Number( + (riskPerpUnit * Number(riskPreview?.k)).toFixed(4), + ) + : perpQty + } onChange={(e) => setPerpQty(Number(e.target.value))} />
- + 0 + ? Number( + (riskOptUnit * Number(riskPreview?.k)).toFixed(4), + ) + : optQty + } onChange={(e) => setOptQty(Number(e.target.value))} />
@@ -895,8 +1042,9 @@ export default function SettingsPage() { {sizingMode === "risk_based" || exitMode === "fixed_usdt" ? (
setNetTarget(Number(e.target.value))} + value={ + sizingMode === "risk_based" ? riskExitUnit : netTarget + } + onChange={(e) => { + const v = Number(e.target.value); + if (sizingMode === "risk_based") { + setRiskExitUnit(v); + const k = Number(riskPreview?.k); + if (Number.isFinite(k) && k > 0) { + setNetTarget(Number((v * k).toFixed(2))); + setPerpQty( + Number((riskPerpUnit * k).toFixed(4)), + ); + setOptQty(Number((riskOptUnit * k).toFixed(4))); + } + } else { + setNetTarget(v); + } + }} />
) : (