diff --git a/backend/app/strategy/risk_sizing.py b/backend/app/strategy/risk_sizing.py index adfbfa7..c3c4e5e 100644 --- a/backend/app/strategy/risk_sizing.py +++ b/backend/app/strategy/risk_sizing.py @@ -714,18 +714,30 @@ def apply_risk_sizing_to_ledger( return r +def _hedge_mode(ledger: Ledger | None = None) -> str: + led = ledger or Ledger() + s = get_settings() + raw = str(led.get_setting_str("hedge_mode", s.hedge_mode) or s.hedge_mode).strip().lower() + return raw if raw in ("perp_option", "option_option") else "perp_option" + + def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]: - """设置页预览:用当前盘口粗估。""" + """设置页/计划页预览:用当前盘口粗估。""" database = db or get_db() ledger = Ledger(database) out: dict[str, Any] = { "sizing_mode": ledger.get_setting_str("sizing_mode", "manual") or "manual", "risk_based": is_risk_based(ledger), + "hedge_mode": _hedge_mode(ledger), } if not is_risk_based(ledger): out["ok"] = True out["detail"] = "当前为手动仓位" return out + + if out["hedge_mode"] == "option_option": + return _preview_oo_sizing(database, ledger, out) + try: from .open_capacity import _index_and_option_ask @@ -764,3 +776,99 @@ def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]: } ) return out + + +def _preview_oo_sizing( + database: Database, ledger: Ledger, out: dict[str, Any] +) -> dict[str, Any]: + """期期预览:出场目标 = 预算 × 盈亏比;有卖一时再估单腿 qty。""" + s = get_settings() + budget, detail, capital = resolve_budget(database) + mg = resolve_martingale(database, ledger=ledger) + ratio = float( + ledger.get_setting_float("oo_reward_ratio", s.oo_reward_ratio) + or s.oo_reward_ratio + ) + cush = float( + ledger.get_setting_float("oo_budget_cushion", s.oo_budget_cushion) + or s.oo_budget_cushion + ) + out["martingale"] = mg + out["risk_effective_loss_pct"] = mg.get("effective_pct") + out["reward_ratio"] = ratio + out["cushion"] = cush + if budget is None: + out["ok"] = False + out["detail"] = f"期期预算失败: {detail}" + return out + exit_target = _round2(float(budget) * max(0.5, ratio)) + out.update( + { + "budget": _round2(float(budget)), + "capital_base": _round2(float(capital)) if capital is not None else None, + "net_profit_target": exit_target, + "k": None, + "perp_qty_eth": 0.0, + } + ) + call_ask = put_ask = idx = None + try: + from .session import get_session + + snap = get_session().snapshot() + idx = snap.index_px + if snap.call and snap.call.ask and float(snap.call.ask) > 0: + call_ask = float(snap.call.ask) + if snap.put and snap.put.ask and float(snap.put.ask) > 0: + put_ask = float(snap.put.ask) + if idx is None and snap.perp and snap.perp.mark_px: + idx = float(snap.perp.mark_px) + except Exception: + pass + if call_ask is None or put_ask is None: + try: + from .open_capacity import _index_and_option_ask + + i2, a2 = _index_and_option_ask() + if idx is None: + idx = i2 + # 回退:单腿 ATM 卖一不够准确,但至少能估数量量级 + if call_ask is None and a2 is not None and float(a2) > 0: + call_ask = float(a2) + if put_ask is None and a2 is not None and float(a2) > 0: + put_ask = float(a2) + except Exception: + pass + if call_ask is None or put_ask is None or call_ask <= 0 or put_ask <= 0: + out["ok"] = True + out["detail"] = "已估出场目标;虚值双腿卖一未齐,数量待开仓时再算" + out["option_qty_eth"] = None + out["sizing_ok"] = False + return out + fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate) + r = compute_oo_sizing( + budget=float(budget), + call_ask=float(call_ask), + put_ask=float(put_ask), + fee_rate=fee_rate, + index_px=float(idx or 0), + cushion=cush, + reward_ratio=ratio, + ) + # 出场始终按全额预算×盈亏比;数量估失败仍返回 ok 以便 Plan 展示目标 + out.update( + { + "ok": True, + "sizing_ok": bool(r.ok), + "detail": "ok" if r.ok else str(r.detail or "期期数量未估出"), + "option_qty_eth": r.qty_eth if r.ok else None, + "call_ask": r.call_ask, + "put_ask": r.put_ask, + "call_premium": r.call_premium if r.ok else None, + "put_premium": r.put_premium if r.ok else None, + "max_loss": r.max_loss if r.ok else None, + "net_profit_target": exit_target, + "index_px": _round2(float(idx)) if idx is not None else None, + } + ) + return out diff --git a/backend/tests/test_oo_selection_sizing.py b/backend/tests/test_oo_selection_sizing.py index 79ac0d2..4e8a056 100644 --- a/backend/tests/test_oo_selection_sizing.py +++ b/backend/tests/test_oo_selection_sizing.py @@ -66,6 +66,29 @@ def test_amplitude_range_pct() -> None: assert abs(a.range_pct - 3.0) < 1e-9 +def test_preview_oo_exit_budget_times_ratio(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("MODE", "SIM") + from app.models.db import Database + from app.sim.ledger import Ledger + from app.strategy.risk_sizing import _preview_oo_sizing + + db = Database(tmp_path / "oo_prev.db") + db.set_setting("hedge_mode", "option_option") + db.set_setting("sizing_mode", "risk_based") + db.set_setting("risk_loss_mode", "percent") + db.set_setting("risk_loss_pct", "1") + db.set_setting("risk_capital_source", "manual") + db.set_setting("risk_manual_capital_usdt", "10000") + db.set_setting("oo_reward_ratio", "2") + monkeypatch.setattr("app.strategy.risk_sizing.get_db", lambda: db) + # 无 session 盘口时仍应给出 预算×比 + out = _preview_oo_sizing(db, Ledger(db), {"hedge_mode": "option_option"}) + assert out.get("ok") is True + assert abs(float(out.get("budget") or 0) - 100.0) < 1e-6 + assert abs(float(out.get("net_profit_target") or 0) - 200.0) < 1e-6 + db.close() + + def test_amplitude_max_gate() -> None: from app.strategy.oo_selection import build_oo_pick_core diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 6a2bdea..42b0067 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -208,9 +208,12 @@ export default function PlanPage() { const netPnl = pos?.net_pnl ?? 0; const movePct = pos?.move_pct ?? 0; const ooRatio = Number(plan?.oo_reward_ratio ?? 2); + const ooBudget = plan?.risk_sizing_preview?.budget; const exitRuleLabel = isOo ? exitTarget != null - ? `${riskLocked ? "锁定 " : ""}净盈≥${fmt(exitTarget)} U(预算×${fmt(ooRatio, 1)})` + ? `${riskLocked ? "锁定 " : ""}净盈≥${fmt(exitTarget)} U(${ + ooBudget != null ? `${fmt(ooBudget)}×` : "预算×" + }${fmt(ooRatio, 1)})` : `净盈≥预算×${fmt(ooRatio, 1)}(达标只平盈利腿)` : exitMode === "premium_multiple" ? `权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}`