Add risk leverage basis option: actual ask vs selection leverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-01 17:01:18 +08:00
parent f1da2778ef
commit 9c515644a3
7 changed files with 201 additions and 7 deletions
+21
View File
@@ -50,6 +50,7 @@ KEYS = (
"option_qty_eth",
"show_manual_trade_buttons",
"sizing_mode",
"risk_leverage_basis",
"risk_loss_mode",
"risk_loss_pct",
"risk_loss_usdt",
@@ -86,6 +87,9 @@ class StrategySettingsBody(BaseModel):
show_manual_trade_buttons: bool | None = None
exchange: str | None = Field(default=None, pattern="^(okx|binance|bn)$")
sizing_mode: str | None = Field(default=None, pattern="^(manual|risk_based)$")
risk_leverage_basis: str | None = Field(
default=None, pattern="^(actual|selection)$"
)
risk_loss_mode: str | None = Field(default=None, pattern="^(percent|absolute)$")
risk_loss_pct: float | None = Field(default=None, ge=0.01, le=100)
risk_loss_usdt: float | None = Field(default=None, ge=0.1, le=1_000_000)
@@ -228,6 +232,23 @@ def _read_settings() -> dict:
in ("manual", "risk_based")
else "manual"
),
"risk_leverage_basis": (
rb
if (
rb := str(
db.get_setting(
"risk_leverage_basis",
str(s.risk_leverage_basis),
)
or s.risk_leverage_basis
or "selection"
)
.strip()
.lower()
)
in ("actual", "selection")
else "selection"
),
"risk_loss_mode": (
lm
if (
+2
View File
@@ -74,6 +74,8 @@ class Settings(BaseSettings):
perp_margin_mode: str = "cross"
min_option_hours: float = 12.0 # 期权最小剩余小时
min_option_leverage: float = 100.0 # 现价/卖一权利金 下限
# 以损定仓权利金口径:actual=盘口卖一;selection=指数/选约杠杆(控节奏,默认)
risk_leverage_basis: str = "selection"
atm_open_offset_enabled: bool = False # 开仓 ATM 偏差限制开关(默认关)
max_atm_open_offset: float = 3.0 # 开启后:|ATM行权价−标的| 上限(点)
# 固定方向:关=现有 ATM/比价规则;开=指定永续多/空,期权 Put/Call 且须实值或平值
+1
View File
@@ -238,6 +238,7 @@ class Database:
"perp_margin_mode": str(s.perp_margin_mode),
"min_option_hours": str(s.min_option_hours),
"min_option_leverage": str(s.min_option_leverage),
"risk_leverage_basis": str(s.risk_leverage_basis),
"atm_open_offset_enabled": str(s.atm_open_offset_enabled),
"max_atm_open_offset": str(s.max_atm_open_offset),
"fixed_direction_enabled": str(s.fixed_direction_enabled),
+66 -6
View File
@@ -55,12 +55,44 @@ class RiskSizingResult:
option_qty_eth: float | None = None
net_profit_target: float | None = None
index_px: float | None = None
option_ask: float | None = None
option_ask: float | None = None # 定仓用权利金(可能为选约杠杆隐含)
actual_option_ask: float | None = None # 盘口真实卖一
leverage_basis: str | None = None # actual | selection
perp_unit: float | None = None
option_unit: float | None = None
exit_unit: float | None = None
def normalize_risk_leverage_basis(raw: str | None, default: str = "selection") -> str:
v = (raw or default or "selection").strip().lower()
if v in ("selection", "min_option_leverage", "select", "选约", "选约杠杆"):
return "selection"
if v in ("actual", "market", "ask", "实际", "实际杠杆"):
return "actual"
return "selection" if default == "selection" else "actual"
def resolve_sizing_option_ask(
*,
index_px: float,
option_ask: float,
leverage_basis: str,
min_option_leverage: float,
) -> tuple[float, str]:
"""
返回 (定仓用卖一, 口径 actual|selection)。
selection:隐含卖一 = 指数 / 选约杠杆;actual:用盘口卖一。
"""
basis = normalize_risk_leverage_basis(leverage_basis, "selection")
if basis == "selection":
lev = float(min_option_leverage)
if lev > 1e-12 and math.isfinite(lev) and index_px > 0:
return float(index_px) / lev, "selection"
# 选约杠杆无效时退回实际卖一,避免拒单
return float(option_ask), "actual"
return float(option_ask), "actual"
def is_risk_based(ledger: Ledger | None = None) -> bool:
led = ledger or Ledger()
mode = (led.get_setting_str("sizing_mode", "manual") or "manual").strip().lower()
@@ -269,13 +301,31 @@ def compute_risk_sizing(
s = get_settings()
fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate)
perp_u, opt_u, exit_u = read_risk_units(ledger)
basis_raw = 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
)
sizing_ask, basis = resolve_sizing_option_ask(
index_px=float(index_px),
option_ask=float(option_ask),
leverage_basis=str(basis_raw),
min_option_leverage=float(min_opt_lev),
)
budget, bud_detail, capital = resolve_budget(database)
if budget is None:
return RiskSizingResult(ok=False, detail=f"以损定仓预算失败: {bud_detail}")
return RiskSizingResult(
ok=False,
detail=f"以损定仓预算失败: {bud_detail}",
leverage_basis=basis,
actual_option_ask=_round2(float(option_ask)),
option_ask=_round2(float(sizing_ask)),
)
r = compute_k(
budget=budget,
index_px=index_px,
option_ask=option_ask,
option_ask=sizing_ask,
fee_rate=fee_rate,
perp_unit=perp_u,
option_unit=opt_u,
@@ -288,7 +338,9 @@ def compute_risk_sizing(
budget=_round2(budget),
capital_base=_round2(capital) if capital is not None else None,
index_px=float(index_px),
option_ask=float(option_ask),
option_ask=_round2(float(sizing_ask)),
actual_option_ask=_round2(float(option_ask)),
leverage_basis=basis,
k=r.k,
perp_unit=perp_u,
option_unit=opt_u,
@@ -307,7 +359,9 @@ def compute_risk_sizing(
option_qty_eth=r.option_qty_eth,
net_profit_target=r.net_profit_target,
index_px=r.index_px,
option_ask=r.option_ask,
option_ask=_round2(float(sizing_ask)),
actual_option_ask=_round2(float(option_ask)),
leverage_basis=basis,
perp_unit=perp_u,
option_unit=opt_u,
exit_unit=exit_u,
@@ -351,8 +405,12 @@ def apply_risk_sizing_to_ledger(
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",
"risk_sizing applied k=%.1f basis=%s sizing_ask=%.4f actual_ask=%.4f "
"perp=%.4f opt=%.4f exit=%.4f max_loss=%.4f budget=%.4f",
r.k or 0,
r.leverage_basis or "?",
r.option_ask or 0,
r.actual_option_ask or 0,
r.perp_qty_eth or 0,
r.option_qty_eth or 0,
r.net_profit_target or 0,
@@ -401,6 +459,8 @@ 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,
"actual_option_ask": r.actual_option_ask,
"leverage_basis": r.leverage_basis,
"perp_unit": perp_u,
"option_unit": opt_u,
"exit_unit": exit_u,
+71
View File
@@ -8,6 +8,8 @@ from app.strategy.risk_sizing import (
BASE_PERP_ETH,
compute_k,
floor_k_1dp,
normalize_risk_leverage_basis,
resolve_sizing_option_ask,
unit_cost,
)
@@ -78,3 +80,72 @@ def test_compute_k_too_small() -> None:
def test_unit_cost() -> None:
assert abs(unit_cost(index_px=2000, option_ask=20, fee_rate=0.0005) - 43.0) < 1e-9
def test_normalize_risk_leverage_basis() -> None:
assert normalize_risk_leverage_basis("actual") == "actual"
assert normalize_risk_leverage_basis("selection") == "selection"
assert normalize_risk_leverage_basis("min_option_leverage") == "selection"
assert normalize_risk_leverage_basis("weird", default="selection") == "selection"
def test_resolve_sizing_ask_selection_vs_actual() -> None:
# 指数 2000、选约杠杆 100 → 隐含卖一 20;实际卖一更便宜 10
sel_ask, basis = resolve_sizing_option_ask(
index_px=2000.0,
option_ask=10.0,
leverage_basis="selection",
min_option_leverage=100.0,
)
assert basis == "selection"
assert abs(sel_ask - 20.0) < 1e-9
act_ask, basis2 = resolve_sizing_option_ask(
index_px=2000.0,
option_ask=10.0,
leverage_basis="actual",
min_option_leverage=100.0,
)
assert basis2 == "actual"
assert abs(act_ask - 10.0) < 1e-9
def test_selection_basis_yields_smaller_k_when_ask_cheap() -> None:
# 预算 43:选约隐含 ask=20 → k=1;若用实际 ask=10 → 单位成本更小 → k 更大
r_sel = compute_k(budget=43.0, index_px=2000.0, option_ask=20.0, fee_rate=0.0005)
r_act = compute_k(budget=43.0, index_px=2000.0, option_ask=10.0, fee_rate=0.0005)
assert r_sel.ok and r_act.ok
assert r_sel.k == 1.0
assert r_act.k is not None and r_act.k > r_sel.k
def test_compute_risk_sizing_respects_basis(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_basis.db")
db.set_setting("sizing_mode", "risk_based")
db.set_setting("risk_loss_mode", "absolute")
db.set_setting("risk_loss_usdt", "43")
db.set_setting("fee_rate", "0.0005")
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")
db.set_setting("risk_leverage_basis", "selection")
r1 = compute_risk_sizing(index_px=2000.0, option_ask=10.0, db=db)
assert r1.ok
assert r1.leverage_basis == "selection"
assert r1.k == 1.0
assert r1.actual_option_ask == 10.0
assert r1.option_ask == 20.0
db.set_setting("risk_leverage_basis", "actual")
r2 = compute_risk_sizing(index_px=2000.0, option_ask=10.0, db=db)
assert r2.ok
assert r2.leverage_basis == "actual"
assert r2.k is not None and r2.k > 1.0
assert r2.option_ask == 10.0
db.close()
+1
View File
@@ -331,6 +331,7 @@ export type StrategySettings = {
option_qty_eth?: number;
show_manual_trade_buttons?: boolean;
sizing_mode?: "manual" | "risk_based";
risk_leverage_basis?: "actual" | "selection";
risk_loss_mode?: "percent" | "absolute";
risk_loss_pct?: number;
risk_loss_usdt?: number;
+39 -1
View File
@@ -89,6 +89,9 @@ export default function SettingsPage() {
const [optQty, setOptQty] = useState(2);
const [showManualTrade, setShowManualTrade] = useState(false);
const [sizingMode, setSizingMode] = useState<"manual" | "risk_based">("manual");
const [riskLeverageBasis, setRiskLeverageBasis] = useState<
"actual" | "selection"
>("selection");
const [riskLossMode, setRiskLossMode] = useState<"percent" | "absolute">(
"percent",
);
@@ -197,6 +200,9 @@ export default function SettingsPage() {
setOptQty(s.option_qty_eth ?? 2);
setShowManualTrade(s.show_manual_trade_buttons === true);
setSizingMode(s.sizing_mode === "risk_based" ? "risk_based" : "manual");
setRiskLeverageBasis(
s.risk_leverage_basis === "actual" ? "actual" : "selection",
);
setRiskLossMode(s.risk_loss_mode === "absolute" ? "absolute" : "percent");
setRiskLossPct(s.risk_loss_pct ?? 1);
setRiskLossUsdt(s.risk_loss_usdt ?? 15);
@@ -358,6 +364,7 @@ export default function SettingsPage() {
close_bid_mark_max_pct: closeDevPct,
show_manual_trade_buttons: showManualTrade,
sizing_mode: sizingMode,
risk_leverage_basis: riskLeverageBasis,
risk_loss_mode: riskLossMode,
risk_loss_pct: riskLossPct,
risk_loss_usdt: riskLossUsdt,
@@ -679,6 +686,30 @@ export default function SettingsPage() {
</div>
{sizingMode === "risk_based" ? (
<>
<div className="field">
<label htmlFor="riskLevBasis"></label>
<select
id="riskLevBasis"
className="mono"
value={riskLeverageBasis}
onChange={(e) =>
setRiskLeverageBasis(
e.target.value === "actual"
? "actual"
: "selection",
)
}
>
<option value="selection">
</option>
<option value="actual"></option>
</select>
<p className="hint" style={{ margin: "0.35rem 0 0" }}>
÷ k便
便 k
</p>
</div>
<div className="field">
<label htmlFor="riskLossMode"></label>
<select
@@ -794,7 +825,14 @@ export default function SettingsPage() {
? bud.toFixed(2)
: "—";
const mxS = Number.isFinite(mx) ? mx.toFixed(2) : "—";
return `k=${pk ?? "—"} · 预算=${budS}U · 估亏=${mxS}U · 永续=${perp ?? "—"} · 期权=${opt ?? "—"} · 出场=${exit ?? "—"}`;
const basis =
riskPreview.leverage_basis === "actual"
? "实际杠杆"
: riskPreview.leverage_basis === "selection"
? "选约杠杆"
: "";
const basisS = basis ? ` · ${basis}` : "";
return `k=${pk ?? "—"} · 预算=${budS}U · 估亏=${mxS}U · 永续=${perp ?? "—"} · 期权=${opt ?? "—"} · 出场=${exit ?? "—"}${basisS}`;
})()}
</div>
</div>