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>
This commit is contained in:
dekun
2026-08-08 14:27:19 +08:00
parent 7db8b724ac
commit b09d1b0886
9 changed files with 343 additions and 44 deletions
+3
View File
@@ -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 (
+7
View File
@@ -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)
+52 -5
View File
@@ -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"),
}
+35
View File
@@ -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
+4
View File
@@ -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 | 初版:设置开关、首页本单、中控展示、出场与停机 |
+10
View File
@@ -5,6 +5,16 @@
---
## 2026-08-08 — 半自动以损定仓预览开仓量
### 变更
1. 半自动开启且以损定仓时:按**盘口卖一**推期权量,再按本单永续:期权配比 ×k 得永续量。
2. 首页「半自动 · 本单」下方实时显示预估开仓数量(卖一/K/k/预算)。
3. 预览与开仓共用半自动单位与净利基数,权利金口径强制实际卖一。
---
## 2026-08-08 — 半自动行权类型与 1:4 配比
### 变更
+1
View File
@@ -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;
+225 -39
View File
@@ -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<MarketSnapshot | null>(null);
const [plan, setPlan] = useState<PlanState | null>(null);
@@ -856,7 +976,7 @@ export default function PlanPage() {
</div>
<div className="mono meta plan-semi-summary">
{(() => {
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`;
})()}
</div>
<div className="mono meta plan-semi-sizing">
{(() => {
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
<span className="meta">
{" "}
</span>
</>
);
}
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`
: ""}
<span className="meta"> · </span>
</>
);
}
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`
: ""}
</>
);
})()}
</div>
<div className="plan-semi-btns">
<button
className="btn ghost"
@@ -932,44 +1150,12 @@ export default function PlanPage() {
</thead>
<tbody>
{(() => {
const all = ladder.rows || [];
let shown = all;
if (semiMny === "atm") {
shown = all.filter((r) => r.tag === "atm").slice(0, 1);
} else if (semiMny === "otm") {
shown = all
.filter(
(r) =>
r.tag === "otm" &&
Math.abs(r.offset) <=
Number(semiOtmOff) + 1e-9,
)
.sort(
(a, b) =>
Math.abs(a.offset) - Math.abs(b.offset),
)
.slice(0, 2);
// Call:虚值高行权在上;Put:虚值低行权在上
shown.sort((a, b) =>
semiView === "short"
? a.strike - b.strike
: b.strike - a.strike,
);
} else {
// 实值:最接近现价的 2 档
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) =>
semiView === "short"
? a.strike - b.strike
: b.strike - a.strike,
);
}
const shown = filterSemiLadderRows(
ladder.rows || [],
semiMny,
Number(semiOtmOff) || 25,
semiView,
);
if (shown.length === 0) {
return (
<tr>
+6
View File
@@ -537,6 +537,12 @@ input {
font-size: 12px;
}
.plan-semi-sizing {
margin-top: 2px !important;
font-size: 12px;
opacity: 0.95;
}
.plan-semi-btns {
display: flex;
flex-wrap: wrap;