From b09d1b088626cfefefd9b19b6e7121084ca9c83d Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 8 Aug 2026 14:27:19 +0800 Subject: [PATCH] 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 --- backend/app/strategy/engine.py | 3 + backend/app/strategy/open_pipeline.py | 7 + backend/app/strategy/risk_sizing.py | 57 +++++- backend/tests/test_risk_sizing.py | 35 ++++ docs/半自动说明.md | 4 + docs/更新说明.md | 10 + frontend/src/api/client.ts | 1 + frontend/src/pages/Plan.tsx | 264 ++++++++++++++++++++++---- frontend/src/styles/app.css | 6 + 9 files changed, 343 insertions(+), 44 deletions(-) diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 63d3f9b..dfdc7f9 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -254,6 +254,9 @@ class StrategyEngine: "oo_put_qty_eth": oo_put_qty, "sizing_mode": sizing_mode, "risk_based": sizing_mode == "risk_based", + "fee_rate": float( + self.ledger.get_setting_float("fee_rate", s.fee_rate) or s.fee_rate + ), "hedge_mode": ( hm if ( diff --git a/backend/app/strategy/open_pipeline.py b/backend/app/strategy/open_pipeline.py index b023e72..88296b8 100644 --- a/backend/app/strategy/open_pipeline.py +++ b/backend/app/strategy/open_pipeline.py @@ -83,10 +83,15 @@ def size_and_gate( led = Ledger(database) semi_perp = None semi_opt = None + semi_exit = None + semi_basis = None if is_semi_auto(led): sp = read_semi_params(led) semi_perp = float(sp["perp_unit"]) semi_opt = float(sp["option_unit"]) + semi_exit = float(sp["perp_exit_unit"]) + # 半自动以损定仓:按盘口卖一推期权量,再按配比乘 k 得永续量 + semi_basis = "actual" if not is_risk_based(led): # 手动仓:半自动直接写入 永续:期权 配比名义 database.set_setting("perp_qty_eth", f"{semi_perp:.4f}") @@ -97,6 +102,8 @@ def size_and_gate( db=database, perp_unit=semi_perp, option_unit=semi_opt, + exit_unit=semi_exit, + leverage_basis=semi_basis, ) if not rs.ok: return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail) diff --git a/backend/app/strategy/risk_sizing.py b/backend/app/strategy/risk_sizing.py index 517defd..87a76fc 100644 --- a/backend/app/strategy/risk_sizing.py +++ b/backend/app/strategy/risk_sizing.py @@ -430,6 +430,8 @@ def compute_risk_sizing( 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) @@ -440,9 +442,16 @@ def compute_risk_sizing( perp_u = float(perp_unit) if option_unit is not None and float(option_unit) > 0: opt_u = float(option_unit) - basis_raw = ledger.get_setting_str( - "risk_leverage_basis", s.risk_leverage_basis - ) or s.risk_leverage_basis + 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 ) @@ -690,6 +699,8 @@ def apply_risk_sizing_to_ledger( 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() @@ -713,6 +724,8 @@ def apply_risk_sizing_to_ledger( db=database, perp_unit=perp_unit, option_unit=option_unit, + exit_unit=exit_unit, + leverage_basis=leverage_basis, ) if not r.ok: return r @@ -793,8 +806,38 @@ def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]: out["ok"] = False out["detail"] = "暂无指数或期权卖一,无法预览" return out - r = compute_risk_sizing(index_px=float(idx), option_ask=float(ask), db=database) + + # 半自动:用本单配比/净利基数,并以盘口卖一定仓(与开仓一致) + 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( @@ -806,7 +849,10 @@ def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]: ) exit_target = r.net_profit_target exit_label = "基数×k" - if exit_mode == "premium_multiple": + 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) @@ -833,6 +879,7 @@ def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]: "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"), } diff --git a/backend/tests/test_risk_sizing.py b/backend/tests/test_risk_sizing.py index 3406926..f90c234 100644 --- a/backend/tests/test_risk_sizing.py +++ b/backend/tests/test_risk_sizing.py @@ -119,6 +119,41 @@ def test_selection_basis_yields_smaller_k_when_ask_cheap() -> None: assert r_act.k is not None and r_act.k > r_sel.k +def test_compute_risk_sizing_unit_overrides(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("MODE", "SIM") + from app.models.db import Database + from app.strategy.risk_sizing import compute_risk_sizing + + db = Database(tmp_path / "risk_units.db") + db.set_setting("sizing_mode", "risk_based") + db.set_setting("risk_loss_mode", "absolute") + db.set_setting("risk_loss_usdt", "83") + db.set_setting("fee_rate", "0.0005") + db.set_setting("risk_leverage_basis", "selection") + db.set_setting("min_option_leverage", "100") + db.set_setting("risk_perp_unit", "1") + db.set_setting("risk_option_unit", "2") + db.set_setting("risk_exit_unit", "15") + + # 覆盖为单位 0.5:4、出场 5,并强制实际卖一(ask=20 → cost=80+3=83 → k=1) + r = compute_risk_sizing( + index_px=2000.0, + option_ask=20.0, + db=db, + perp_unit=0.5, + option_unit=4.0, + exit_unit=5.0, + leverage_basis="actual", + ) + assert r.ok + assert r.leverage_basis == "actual" + assert r.k == 1.0 + assert r.perp_qty_eth == 0.5 + assert r.option_qty_eth == 4.0 + assert r.net_profit_target == 5.0 + db.close() + + def test_compute_risk_sizing_respects_basis(tmp_path, monkeypatch) -> None: monkeypatch.setenv("MODE", "SIM") from app.models.db import Database diff --git a/docs/半自动说明.md b/docs/半自动说明.md index 647e5bf..71f3bec 100644 --- a/docs/半自动说明.md +++ b/docs/半自动说明.md @@ -46,6 +46,9 @@ 名义配比:`semi_perp_unit` : `semi_option_unit`(默认 **1:4**);以损定仓时作单位再乘 k,手动仓直接写入名义。最短剩余小时默认 ≥30h。 +**以损定仓(半自动)**:系统设置 `sizing_mode=risk_based` 时,开仓前用**盘口卖一**估算单位成本 → 预算反推 k → +`期权量 = 期权单位 × k`,`永续量 = 永续单位 × k`(配比不变)。首页「半自动 · 本单」下方按当前报价档卖一实时预览开仓量。 + --- ## 3. 出场 @@ -109,5 +112,6 @@ Fleet `strategy` 透出半自动字段。监控模式列在永期开启半自动 | 日期 | 说明 | |------|------| +| 2026-08-08 | 以损定仓按卖一推期权量、配比乘 k 得永续;本单面板预览开仓量 | | 2026-08-08 | 行权类型 itm/atm/otm;虚值偏离可配;永续:期权配比默认 1:4 | | 2026-08-08 | 初版:设置开关、首页本单、中控展示、出场与停机 | diff --git a/docs/更新说明.md b/docs/更新说明.md index dfdf6bb..31544bc 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,16 @@ --- +## 2026-08-08 — 半自动以损定仓预览开仓量 + +### 变更 + +1. 半自动开启且以损定仓时:按**盘口卖一**推期权量,再按本单永续:期权配比 ×k 得永续量。 +2. 首页「半自动 · 本单」下方实时显示预估开仓数量(卖一/K/k/预算)。 +3. 预览与开仓共用半自动单位与净利基数,权利金口径强制实际卖一。 + +--- + ## 2026-08-08 — 半自动行权类型与 1:4 配比 ### 变更 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 797a1e0..93c3238 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -393,6 +393,7 @@ export type PlanState = { oo_put_qty_eth?: number; sizing_mode?: "manual" | "risk_based"; risk_based?: boolean; + fee_rate?: number; ledger: { equity: number; available: number; reserved: number }; mode?: "SIM" | "LIVE"; sim?: boolean; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index beec799..1e5e264 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -105,6 +105,126 @@ function fmtBidLiquidity( return `${px} / ${sz}`; } +type LadderRow = { + strike: number; + offset: number; + tag: string; + ask: number | null; + ask_sz: number | null; + lev: number | null; + inst_id?: string | null; +}; + +/** 与报价表相同的候选档:ATM 1 / OTM 最近 2 / ITM 最近 2 */ +function filterSemiLadderRows( + all: LadderRow[], + moneyness: "itm" | "atm" | "otm", + otmOff: number, + view: "long" | "short", +): LadderRow[] { + if (moneyness === "atm") { + return all.filter((r) => r.tag === "atm").slice(0, 1); + } + if (moneyness === "otm") { + const shown = all + .filter( + (r) => r.tag === "otm" && Math.abs(r.offset) <= Number(otmOff) + 1e-9, + ) + .sort((a, b) => Math.abs(a.offset) - Math.abs(b.offset)) + .slice(0, 2); + shown.sort((a, b) => + view === "short" ? a.strike - b.strike : b.strike - a.strike, + ); + return shown; + } + const shown = all + .filter((r) => r.tag === "itm") + .sort((a, b) => Math.abs(a.offset) - Math.abs(b.offset)) + .slice(0, 2); + shown.sort((a, b) => + view === "short" ? a.strike - b.strike : b.strike - a.strike, + ); + return shown; +} + +/** 定仓用:取最接近现价且有卖一的一档 */ +function pickSizingAskRow(rows: LadderRow[]): LadderRow | null { + const withAsk = rows.filter( + (r) => r.ask != null && Number.isFinite(r.ask) && Number(r.ask) > 0, + ); + if (withAsk.length === 0) return null; + return [...withAsk].sort( + (a, b) => Math.abs(a.offset) - Math.abs(b.offset), + )[0]; +} + +function floorK1dp(kRaw: number): number { + if (!(kRaw > 0) || !Number.isFinite(kRaw)) return 0; + return Math.floor(kRaw * 10 + 1e-12) / 10; +} + +/** 卖一推期权量,再按永续:期权单位配比得永续量(与后端 compute_k 一致) */ +function estimateSemiRiskQty(args: { + budget: number; + indexPx: number; + optionAsk: number; + feeRate: number; + perpUnit: number; + optionUnit: number; + exitUnit: number; +}): { + ok: boolean; + detail: string; + k?: number; + perpQty?: number; + optionQty?: number; + maxLoss?: number; + netTarget?: number; +} { + const { + budget, + indexPx, + optionAsk, + feeRate, + perpUnit, + optionUnit, + exitUnit, + } = args; + if (!(budget > 0) || !(indexPx > 0) || !(optionAsk > 0)) { + return { ok: false, detail: "缺预算/指数/卖一" }; + } + if (!(perpUnit > 0) || !(optionUnit > 0) || !(exitUnit > 0)) { + return { ok: false, detail: "配比/净利基数须 > 0" }; + } + const cost1 = optionAsk * optionUnit + indexPx * feeRate * 3; + if (!(cost1 > 1e-12)) return { ok: false, detail: "单位成本无效" }; + let k = floorK1dp(budget / cost1); + if (k < 0.1 - 1e-12) { + return { + ok: false, + detail: `预算不足以开最小仓(单位成本≈${cost1.toFixed(2)}U)`, + }; + } + while (k >= 0.1 - 1e-12) { + const prem = optionAsk * optionUnit * k; + const fee = indexPx * feeRate * 3 * k; + const mx = prem + fee; + if (mx <= budget + 1e-6) { + return { + ok: true, + detail: "ok", + k, + perpQty: Number((perpUnit * k).toFixed(4)), + optionQty: Number((optionUnit * k).toFixed(4)), + maxLoss: Number(mx.toFixed(2)), + netTarget: Number((exitUnit * k).toFixed(2)), + }; + } + k = Math.round((k - 0.1) * 10) / 10; + } + return { ok: false, detail: "预算内无合规 k" }; +} + export default function PlanPage() { const [snap, setSnap] = useState(null); const [plan, setPlan] = useState(null); @@ -856,7 +976,7 @@ export default function PlanPage() {
{(() => { - const idx = snap?.index_px; + const idx = snap?.index_px ?? ladder?.index_px; if (idx == null || !Number.isFinite(Number(idx))) { return `净利目标≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U`; } @@ -868,6 +988,104 @@ export default function PlanPage() { return `${fmtExPx("index", n)}→${fmtExPx("index", tgt)} · 净利≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U`; })()}
+
+ {(() => { + const shown = filterSemiLadderRows( + ladder?.rows || [], + semiMny, + Number(semiOtmOff) || 25, + semiView, + ); + const pick = pickSizingAskRow(shown); + const ask = pick?.ask != null ? Number(pick.ask) : null; + const idx = + ladder?.index_px != null + ? Number(ladder.index_px) + : snap?.index_px != null + ? Number(snap.index_px) + : null; + if (!riskBased) { + return ( + <> + 手动仓 · 开仓 期权 {fmt(semiOptU, 2)} ETH / 永续{" "} + {fmt(semiPerpU, 2)} ETH + + {" "} + (系统设置可开以损定仓) + + + ); + } + if (riskLocked) { + return ( + <> + 以损定仓(锁) · 期权{" "} + {fmt(plan.option_qty_eth ?? pos?.option_qty_eth, 2)} ETH / + 永续 {fmt(plan.perp_qty_eth ?? pos?.perp_qty_eth, 2)} ETH + {plan.risk_last_k != null + ? ` · k=${fmt(plan.risk_last_k, 1)}` + : ""} + + ); + } + const budget = Number(plan.risk_sizing_preview?.budget); + const fee = Number(plan.fee_rate ?? 0.0005); + if ( + ask == null || + idx == null || + !Number.isFinite(budget) || + budget <= 0 + ) { + const prev = plan.risk_sizing_preview; + if (prev?.ok && prev.option_qty_eth != null) { + return ( + <> + 以损定仓 · 期权 {fmt(Number(prev.option_qty_eth), 2)} ETH + / 永续 {fmt(Number(prev.perp_qty_eth), 2)} ETH + {prev.k != null ? ` · k=${fmt(Number(prev.k), 1)}` : ""} + {prev.budget != null + ? ` · 预算${fmt(Number(prev.budget), 2)}U` + : ""} + · 待本单卖一 + + ); + } + return ( + <> + 以损定仓 ·{" "} + {prev?.ok === false + ? String(prev.detail || "预览失败") + : "待卖一/预算推算开仓量"} + + ); + } + const est = estimateSemiRiskQty({ + budget, + indexPx: idx, + optionAsk: ask, + feeRate: Number.isFinite(fee) && fee >= 0 ? fee : 0.0005, + perpUnit: Number(semiPerpU) || 1, + optionUnit: Number(semiOptU) || 4, + exitUnit: Number(semiExitU) || 5, + }); + if (!est.ok) { + return <>以损定仓 · {est.detail}; + } + return ( + <> + 以损定仓 · 期权 {fmt(est.optionQty, 2)} ETH / 永续{" "} + {fmt(est.perpQty, 2)} ETH · k={fmt(est.k, 1)} · 预算 + {fmt(budget, 2)}U · 卖一{fmtExPx("option", ask)} + {pick != null + ? ` · K${Math.round(pick.strike)}` + : ""} + {est.netTarget != null + ? ` · 净利目标≈${fmt(est.netTarget, 2)}U` + : ""} + + ); + })()} +