diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index d5154e1..2e979c7 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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 ( diff --git a/backend/app/config.py b/backend/app/config.py index 45e3c6f..5b683bf 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 且须实值或平值 diff --git a/backend/app/models/db.py b/backend/app/models/db.py index 6d1d8ef..ae9b848 100644 --- a/backend/app/models/db.py +++ b/backend/app/models/db.py @@ -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), diff --git a/backend/app/strategy/risk_sizing.py b/backend/app/strategy/risk_sizing.py index 8040708..855a9b9 100644 --- a/backend/app/strategy/risk_sizing.py +++ b/backend/app/strategy/risk_sizing.py @@ -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, diff --git a/backend/tests/test_risk_sizing.py b/backend/tests/test_risk_sizing.py index c5ccec7..a645856 100644 --- a/backend/tests/test_risk_sizing.py +++ b/backend/tests/test_risk_sizing.py @@ -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() diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1f73ada..6f168de 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 29c3d23..5f14bd0 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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() { {sizingMode === "risk_based" ? ( <> +
+ 选约杠杆:按「指数÷期权杠杆≥」估权利金算 k,权利金再便宜也不放大仓位。 + 实际杠杆:按盘口卖一定仓,便宜时 k 会变大。 +
+