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:
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 | 初版:设置开关、首页本单、中控展示、出场与停机 |
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-08 — 半自动以损定仓预览开仓量
|
||||
|
||||
### 变更
|
||||
|
||||
1. 半自动开启且以损定仓时:按**盘口卖一**推期权量,再按本单永续:期权配比 ×k 得永续量。
|
||||
2. 首页「半自动 · 本单」下方实时显示预估开仓数量(卖一/K/k/预算)。
|
||||
3. 预览与开仓共用半自动单位与净利基数,权利金口径强制实际卖一。
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-08 — 半自动行权类型与 1:4 配比
|
||||
|
||||
### 变更
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user