Add risk leverage basis option: actual ask vs selection leverage.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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 且须实值或平值
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user