diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index dd2ecf7..868543b 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -1165,14 +1165,9 @@ fillExpSelect($("hp-oo-exp-select"), d); renderListStrikes(); renderTStrikes(); - if (d.index_px) { - const idx = Number(d.index_px); - if ($("hp-target-up") && !$("hp-target-up").value) { - $("hp-target-up").value = String(Math.round(idx * 1.03)); - } - if ($("hp-target-down") && !$("hp-target-down").value) { - $("hp-target-down").value = String(Math.round(idx * 0.97)); - } + // 期期盈亏比默认 2,不再用指数自动填上破/下破 + if ($("hp-oo-rr") && !$("hp-oo-rr").value) { + $("hp-oo-rr").value = "2"; } } @@ -1581,8 +1576,7 @@ if ($("hp-contracts")) $("hp-contracts").value = ""; if ($("hp-tp")) $("hp-tp").value = ""; if ($("hp-sl")) $("hp-sl").value = ""; - if ($("hp-target-up")) $("hp-target-up").value = ""; - if ($("hp-target-down")) $("hp-target-down").value = ""; + if ($("hp-oo-rr")) $("hp-oo-rr").value = "2"; if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; if ($("hp-premium-line")) $("hp-premium-line").textContent = ""; if ($("hp-oo-sheets-a")) { @@ -1618,16 +1612,12 @@ if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { throw new Error("期期两腿须为平值或虚值,不可选实值"); } - const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0); - const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0); - if (!up || !down) throw new Error("请填写上破与下破目标价"); - if (up <= down) throw new Error("上破目标价必须大于下破目标价"); + const rr = numInput("hp-oo-rr", 2); + if (!(rr > 0)) throw new Error("请填写盈亏比(相对权利金,默认2)"); body = { plan_type: "options_options", - target_price_up: up, - target_price_down: down, - target_price: up, - index_px: indexPx() || (up + down) / 2, + oo_profit_rr: rr, + index_px: indexPx() || 0, leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), }; @@ -1719,28 +1709,44 @@ fmt(s.premium_paid) + (s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : ""); } else { - const upTot = s.at_target_up_total != null ? s.at_target_up_total : s.at_target_total; - const dnTot = s.at_target_down_total; - let rrLine = ""; - if (s.rr_at_up != null || s.rr_at_down != null) { - rrLine = - " · 盈亏比 上破 " + - fmtRr(s.rr_at_up) + - (dnTot != null ? " / 下破 " + fmtRr(s.rr_at_down) : "") + - '(亏=全额保费 ' + - fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) + - ")"; + const rr = s.oo_profit_rr != null ? s.oo_profit_rr : s.rr_target; + const tgt = s.target_profit != null ? s.target_profit : s.at_target_total; + if (rr != null) { + summary.innerHTML = + "盈亏比 ×" + + fmt(rr, 2) + + " · 目标盈利 " + + fmtPnlHtml(tgt) + + " · 到期现价 " + + fmtPnlHtml(s.expiry_flat_total) + + " · 保费 " + + fmt(s.premium_paid) + + '(达标全平;不达标等到期)' + + (s.expiry_is_loss ? " · 到期现价情景为亏" : ""); + } else { + const upTot = s.at_target_up_total != null ? s.at_target_up_total : s.at_target_total; + const dnTot = s.at_target_down_total; + let rrLine = ""; + if (s.rr_at_up != null || s.rr_at_down != null) { + rrLine = + " · 盈亏比 上破 " + + fmtRr(s.rr_at_up) + + (dnTot != null ? " / 下破 " + fmtRr(s.rr_at_down) : "") + + '(亏=全额保费 ' + + fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) + + ")"; + } + summary.innerHTML = + "上破 " + + fmtPnlHtml(upTot) + + (dnTot != null ? " · 下破 " + fmtPnlHtml(dnTot) : "") + + " · 到期现价 " + + fmtPnlHtml(s.expiry_flat_total) + + " · 保费 " + + fmt(s.premium_paid) + + rrLine + + (s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : ""); } - summary.innerHTML = - "上破 " + - fmtPnlHtml(upTot) + - (dnTot != null ? " · 下破 " + fmtPnlHtml(dnTot) : "") + - " · 到期现价 " + - fmtPnlHtml(s.expiry_flat_total) + - " · 保费 " + - fmt(s.premium_paid) + - rrLine + - (s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : ""); } } if (!tbody) return; @@ -2057,8 +2063,7 @@ "hp-tp", "hp-sl", "hp-sheets", - "hp-target-up", - "hp-target-down", + "hp-oo-rr", ]); if ($("hp-preview-btn")) $("hp-preview-btn").addEventListener("click", function () { @@ -2171,6 +2176,9 @@ if (p.plan_type === "perp_options") { return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl); } + if (p.oo_profit_rr != null && Number(p.oo_profit_rr) > 0) { + return "盈亏比 ×" + fmt(p.oo_profit_rr, 2) + "(达标全平)"; + } return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price); } @@ -2330,6 +2338,8 @@ target_win_leg: "期期平盈利腿", target_up_win_leg: "期期上破·平盈利腿", target_down_win_leg: "期期下破·平盈利腿", + oo_rr_target: "期期盈亏比达标", + oo_rr_closing: "期期盈亏比平仓中", oo_rest_closing: "期期全平·清残腿中", oo_rest_closed: "期期全平·两腿已平", orphaned_after_tp: "止盈后持有至到期", @@ -2404,6 +2414,11 @@ "x · 张数 " + fmt(p.perp_size, 4) + ""; + } else if (p.oo_profit_rr != null && Number(p.oo_profit_rr) > 0) { + html += + "
盈亏比 ×" + + fmt(p.oo_profit_rr, 2) + + "(浮盈达标全平;不达标等到期)
"; } else { html += "
目标价 上破 " + @@ -2626,17 +2641,13 @@ if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { throw new Error("期期两腿须为平值或虚值,不可选实值"); } - const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0); - const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0); - if (!up || !down) throw new Error("请填写上破与下破目标价"); - if (up <= down) throw new Error("上破目标价必须大于下破目标价"); + const rr = numInput("hp-oo-rr", 2); + if (!(rr > 0)) throw new Error("请填写盈亏比(相对权利金,默认2)"); body = { plan_type: "options_options", underlying: state.underlying, - target_price_up: up, - target_price_down: down, - target_price: up, - index_px: indexPx() || (up + down) / 2, + oo_profit_rr: rr, + index_px: indexPx() || 0, oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry", oo_sheets_mode: state.ooSheetsMode || "same_sheets", leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), diff --git a/lib/common/static/options_review.js b/lib/common/static/options_review.js index d4d7e49..8779dc9 100644 --- a/lib/common/static/options_review.js +++ b/lib/common/static/options_review.js @@ -76,6 +76,8 @@ target_win_leg: "期期平盈利腿", target_up_win_leg: "期期上破·平盈利腿", target_down_win_leg: "期期下破·平盈利腿", + oo_rr_target: "期期盈亏比达标", + oo_rr_closing: "期期盈亏比平仓中", oo_rest_closing: "期期全平·清残腿中", oo_rest_closed: "期期全平·两腿已平", orphaned_after_tp: "止盈后持有至到期", diff --git a/lib/hedge_plan/hedge_plan_calc_lib.py b/lib/hedge_plan/hedge_plan_calc_lib.py index e508c21..bd55a3e 100644 --- a/lib/hedge_plan/hedge_plan_calc_lib.py +++ b/lib/hedge_plan/hedge_plan_calc_lib.py @@ -444,6 +444,7 @@ def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]: def build_options_options_preview( *, + profit_rr: float | None = None, target_price: float | None = None, target_price_up: float | None = None, target_price_down: float | None = None, @@ -451,7 +452,11 @@ def build_options_options_preview( leg_a: dict[str, Any], leg_b: dict[str, Any], ) -> dict[str, Any]: - """期期情景:上破/下破目标价 / 到期现价 / 最大保费损耗.""" + """期期情景:盈亏比达标 / 到期现价 / 最大保费损耗. + + profit_rr=2 表示目标盈利=2×权利金;中途不达标则等到期. + 仍接受旧上破/下破参数仅作兼容测算. + """ def _leg_pnl(leg: dict[str, Any], spot: float) -> float: return option_expiry_pnl( @@ -463,15 +468,73 @@ def build_options_options_preview( premium_paid=float(leg.get("premium_paid") or 0), ) - # 兼容旧单目标:若未传上下目标则用 target_price 填两边 + prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0) + a_flat = _leg_pnl(leg_a, index_px) + b_flat = _leg_pnl(leg_b, index_px) + flat_total = a_flat + b_flat + + rr = None + if profit_rr not in (None, ""): + try: + rr = float(profit_rr) + except (TypeError, ValueError): + rr = None + if rr is not None and rr > 0: + target_pnl = rr * prem + return { + "plan_type": "options_options", + "premium_paid": round(prem, 6), + "oo_profit_rr": round(rr, 4), + "target_profit": round(target_pnl, 4), + "scenarios": [ + { + "id": "rr_target", + "label": f"盈亏比×{rr:g}", + "spot": None, + "leg_a_pnl": None, + "leg_b_pnl": None, + "total": round(target_pnl, 4), + "note": f"两腿合计浮盈≥{rr:g}×权利金({round(prem, 4)})时全平;不达标等到期", + }, + { + "id": "expiry_flat", + "label": "到期·现价(未达标)", + "spot": index_px, + "leg_a_pnl": round(a_flat, 4), + "leg_b_pnl": round(b_flat, 4), + "total": round(flat_total, 4), + "note": "中途未达盈亏比则持有至到期结算", + }, + { + "id": "max_premium_loss", + "label": "最大保费损耗", + "spot": None, + "leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4), + "leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4), + "total": round(-prem, 4), + "note": "双腿权利金全部损失", + }, + ], + "summary": { + "oo_profit_rr": round(rr, 4), + "target_profit": round(target_pnl, 4), + "at_target_total": round(target_pnl, 4), + "expiry_flat_total": round(flat_total, 4), + "premium_paid": round(prem, 6), + "expiry_is_loss": flat_total <= 0, + "rr_risk_premium": round(prem, 6), + "rr_target": round(rr, 4), + }, + } + + # 兼容旧上破/下破测算 up = target_price_up if target_price_up is not None else target_price down = target_price_down if target_price_down is not None else target_price if up is None or down is None: - raise ValueError("缺少上破/下破目标价") + raise ValueError("请填写盈亏比(相对权利金,默认2)") up_f = float(up) down_f = float(down) - prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0) a_up = _leg_pnl(leg_a, up_f) b_up = _leg_pnl(leg_b, up_f) at_up = a_up + b_up @@ -482,15 +545,10 @@ def build_options_options_preview( at_dn = a_dn + b_dn win_dn = "a" if a_dn >= b_dn else "b" - a_flat = _leg_pnl(leg_a, index_px) - b_flat = _leg_pnl(leg_b, index_px) - flat_total = a_flat + b_flat - expiry_loss = flat_total if flat_total <= 0 else flat_total - return { "plan_type": "options_options", "premium_paid": round(prem, 6), - "target_price": up_f, # 兼容旧字段,取上破 + "target_price": up_f, "target_price_up": up_f, "target_price_down": down_f, "winner_at_up": win_up, @@ -538,10 +596,9 @@ def build_options_options_preview( "at_target_up_total": round(at_up, 4), "at_target_down_total": round(at_dn, 4), "at_target_total": round(at_up, 4), - "expiry_flat_total": round(expiry_loss, 4), + "expiry_flat_total": round(flat_total, 4), "premium_paid": round(prem, 6), "expiry_is_loss": flat_total <= 0, - # 盈亏比:盈利/全亏保费(风险=权利金全损) "rr_risk_premium": round(prem, 6), "rr_at_up": round(at_up / prem, 4) if prem > 0 else None, "rr_at_down": round(at_dn / prem, 4) if prem > 0 else None, diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py index 299efa6..622dec0 100644 --- a/lib/hedge_plan/hedge_plan_db.py +++ b/lib/hedge_plan/hedge_plan_db.py @@ -72,6 +72,8 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None: ) _ensure_column(conn, "hedge_plans", "target_price_up", "REAL") _ensure_column(conn, "hedge_plans", "target_price_down", "REAL") + # 期期:目标盈亏比=目标盈利/权利金(如 2=盈利 2 倍权利金);不达标则等到期 + _ensure_column(conn, "hedge_plans", "oo_profit_rr", "REAL") # close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状) _ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT") # 永期「以期权为主」 @@ -272,7 +274,7 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s rows = conn.execute( """ SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down, - l.inst_id, l.opt_type + p.oo_profit_rr, l.inst_id, l.opt_type FROM hedge_plans p JOIN hedge_plan_legs l ON l.plan_id = p.id WHERE p.plan_type = 'options_options' @@ -287,10 +289,36 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s for raw in rows: row = dict(raw) inst_id = str(row.get("inst_id") or "") + if not inst_id or inst_id in out: + continue opt_type = str(row.get("opt_type") or "").upper() + rr = _sf(row.get("oo_profit_rr")) target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down") target_f = _sf(target) - if not inst_id or target_f is None or target_f <= 0 or inst_id in out: + # 盈亏比模式无指数目标价;旧上破/下破计划仍透出 target_index 只读展示 + if rr is not None and rr > 0: + out[inst_id] = { + "plan_id": int(row["plan_id"]), + "inst_id": inst_id, + "underlying": row.get("underlying"), + "opt_type": opt_type, + "target_index": None, + "oo_profit_rr": rr, + "plan_type": "options_options", + "managed_by": "hedge_plan", + } + continue + if target_f is None or target_f <= 0: + # 无目标价也标记托管,避免期权页误拆组 + out[inst_id] = { + "plan_id": int(row["plan_id"]), + "inst_id": inst_id, + "underlying": row.get("underlying"), + "opt_type": opt_type, + "target_index": None, + "plan_type": "options_options", + "managed_by": "hedge_plan", + } continue out[inst_id] = { "plan_id": int(row["plan_id"]), diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py index 53addea..b709bc7 100644 --- a/lib/hedge_plan/hedge_plan_monitor_lib.py +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -999,6 +999,8 @@ def _tick_oo_close_rest( "target_up_win_leg", "target_down_win_leg", "oo_rest_closing", + "oo_rr_closing", + "oo_rr_target", "", ) if reason0 not in allowed_reasons and not ( @@ -1049,7 +1051,113 @@ def _tick_oo_close_rest( def _tick_oo_target( cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] ) -> Optional[dict[str, Any]]: - """期期:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿.""" + """期期止盈:优先盈亏比(浮盈≥rr×权利金则两腿全平);否则兼容旧上破/下破.""" + rr = _sf(plan.get("oo_profit_rr")) + if rr is not None and rr > 0: + return _tick_oo_rr_target(cfg, conn, plan, legs, rr=float(rr)) + return _tick_oo_price_target(cfg, conn, plan, legs) + + +def _tick_oo_rr_target( + cfg: dict[str, Any], + conn: Any, + plan: dict[str, Any], + legs: list[dict[str, Any]], + *, + rr: float, +) -> Optional[dict[str, Any]]: + """浮盈(买一回收−权利金)≥盈亏比×总权利金 → 两腿全平;不达标则等到期.""" + open_legs = _oo_option_legs(legs, statuses=("open",)) + if len(open_legs) < 1: + return None + premium = float(plan.get("premium_total") or 0) + if premium <= 0: + premium = sum(float(x.get("premium") or 0) for x in open_legs) + if premium <= 0: + return None + need = float(rr) * premium + quote_fn = cfg.get("quote_option_contract") + ex = cfg.get("exchange_options") + if not callable(quote_fn) or ex is None: + return None + idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) + total_pnl = 0.0 + missing_bid = 0 + for leg in open_legs: + inst = str(leg.get("inst_id") or "") + bid = None + try: + q = quote_fn(ex, inst) if inst else {} + if isinstance(q, dict) and q.get("ok"): + bid = _sf(q.get("bid")) + except Exception: + bid = None + if bid is None or float(bid) <= 0: + missing_bid += 1 + # 无买一时用内在价值兜底,避免短暂无盘口卡住;两腿都无买一则本轮跳过 + total_pnl += _estimate_leg_close_pnl(leg, idx, None) + else: + total_pnl += _estimate_leg_close_pnl(leg, idx, float(bid)) + if missing_bid >= len(open_legs): + return None + if total_pnl + 1e-9 < need: + return None + + acted = False + for leg in list(open_legs): + close_r = _sell_option( + cfg, inst_id=str(leg.get("inst_id") or ""), sheets=float(leg.get("size") or 1) + ) + if not close_r.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期期盈亏比达标·平仓失败(将重试)", + plan_id=plan.get("id"), + detail=( + f"目标 {rr:g}×权利金={need:.4f};估算浮盈 {total_pnl:.4f}; " + f"{close_r.get('msg') or close_r}" + ), + ), + ) + update_plan(conn, int(plan["id"]), close_reason="oo_rr_closing") + return { + "plan_id": plan["id"], + "msg": "盈亏比达标但平仓失败", + "close": close_r, + "retry": True, + "rr": rr, + "need": need, + "mtm": total_pnl, + } + bid = _sf(close_r.get("bid")) + est = _estimate_leg_close_pnl(leg, idx, bid) + pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "oo_rr_target", _now(), round(pnl, 4), leg["id"]), + ) + acted = True + + if not acted: + return None + legs2 = get_plan_legs(conn, int(plan["id"])) + still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry")) + if still_open: + update_plan(conn, int(plan["id"]), close_reason="oo_rr_closing") + return { + "plan_id": plan["id"], + "msg": "盈亏比达标·部分已平,继续重试", + "remaining": len(still_open), + "rr": rr, + } + return _finalize_oo_all_closed(cfg, conn, plan, legs2, reason="oo_rr_target") + + +def _tick_oo_price_target( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] +) -> Optional[dict[str, Any]]: + """旧逻辑:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿.""" idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) if idx is None: return None diff --git a/lib/hedge_plan/hedge_plan_notify_lib.py b/lib/hedge_plan/hedge_plan_notify_lib.py index 2e8ce7d..aa440ae 100644 --- a/lib/hedge_plan/hedge_plan_notify_lib.py +++ b/lib/hedge_plan/hedge_plan_notify_lib.py @@ -46,13 +46,22 @@ def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[ ] ) else: - lines.extend( - [ - f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}" - f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}", - f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", - ] - ) + rr = plan.get("oo_profit_rr") + if rr not in (None, ""): + lines.extend( + [ + f"🎯 盈亏比:{_fmt(rr)}×权利金(达标全平;不达标等到期)", + f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", + ] + ) + else: + lines.extend( + [ + f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}" + f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}", + f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", + ] + ) if legs: for leg in legs: role = leg.get("leg_role") or "" @@ -81,6 +90,8 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str: "target_win_leg": "期期已平盈利腿(中间态)", "target_up_win_leg": "期期上破·已平盈利腿", "target_down_win_leg": "期期下破·已平盈利腿", + "oo_rr_target": "期期盈亏比达标·两腿已平", + "oo_rr_closing": "期期盈亏比达标·平仓中", "oo_rest_closing": "期期全平·清残腿中", "oo_rest_closed": "期期全平·两腿已平", "oo_expiry_loss": "期期到期无盈利·总亏损", @@ -153,7 +164,18 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo "target_up_win_leg", "target_down_win_leg", "oo_rest_closing", + "oo_rr_closing", ) and (plan.get("status") or "") != "closed": + if "oo_rr" in str(plan.get("close_reason") or ""): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期期盈亏比达标·平仓进行中", + plan_id=plan.get("id"), + detail=f"盈亏比 {_fmt(plan.get('oo_profit_rr'))}×权利金", + ), + ) + return True side = "上破" if "up" in str(plan.get("close_reason")) else ( "下破" if "down" in str(plan.get("close_reason")) else "目标价" ) diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index d0180d4..8e554e9 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -1146,20 +1146,31 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: b = body.get("leg_b") or {} if not a.get("inst_id") or not b.get("inst_id"): return "请选用两条期权腿" - up = body.get("target_price_up") - down = body.get("target_price_down") - legacy = body.get("target_price") - if up in (None, "") and legacy not in (None, ""): - up = legacy - if down in (None, "") and legacy not in (None, ""): - down = legacy - if up in (None, "") or down in (None, ""): - return "请填写上破与下破目标价" - try: - if float(up) <= float(down): - return "上破目标价必须大于下破目标价" - except (TypeError, ValueError): - return "目标价无效" + rr_raw = body.get("oo_profit_rr") + if rr_raw in (None, ""): + rr_raw = body.get("profit_rr") + if rr_raw not in (None, ""): + try: + rr = float(rr_raw) + except (TypeError, ValueError): + return "盈亏比无效" + if rr <= 0: + return "盈亏比须大于 0" + else: + up = body.get("target_price_up") + down = body.get("target_price_down") + legacy = body.get("target_price") + if up in (None, "") and legacy not in (None, ""): + up = legacy + if down in (None, "") and legacy not in (None, ""): + down = legacy + if up in (None, "") or down in (None, ""): + return "请填写盈亏比(相对权利金,默认2)" + try: + if float(up) <= float(down): + return "上破目标价必须大于下破目标价" + except (TypeError, ValueError): + return "目标价无效" from lib.hedge_plan.hedge_plan_moneyness_lib import ( parse_strike_from_inst, validate_oo_legs_moneyness, @@ -1179,11 +1190,6 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: return {"opt_type": opt_type, "strike": strike} index_px = body.get("index_px") - if index_px in (None, ""): - try: - index_px = (float(up) + float(down)) / 2.0 - except (TypeError, ValueError): - index_px = None money_err = validate_oo_legs_moneyness( _leg_for_money(a), _leg_for_money(b), diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index 32958b3..7d32ff0 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -537,27 +537,25 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any premium = (float(a.get("premium") or 0) if a_ok else 0.0) + ( float(b.get("premium") or 0) if b_ok else 0.0 ) + rr_raw = body.get("oo_profit_rr") + if rr_raw in (None, ""): + rr_raw = body.get("profit_rr") + try: + oo_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0 + except (TypeError, ValueError): + oo_rr = 2.0 + if oo_rr <= 0: + oo_rr = 2.0 plan_id = insert_plan( conn, { "plan_type": "options_options", "status": "partial" if is_partial else "active", "underlying": str(body.get("underlying") or "ETH").upper(), - "target_price": float( - body.get("target_price_up") - or body.get("target_price") - or 0 - ), - "target_price_up": float( - body.get("target_price_up") - or body.get("target_price") - or 0 - ), - "target_price_down": float( - body.get("target_price_down") - or body.get("target_price") - or 0 - ), + "target_price": None, + "target_price_up": None, + "target_price_down": None, + "oo_profit_rr": oo_rr, "sizing_mode_at_open": load_position_sizing_mode(), "premium_total": premium, "oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")), @@ -1238,20 +1236,24 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]: def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness - up = body.get("target_price_up") - down = body.get("target_price_down") - legacy = body.get("target_price") - if up in (None, "") and legacy not in (None, ""): - up = legacy - if down in (None, "") and legacy not in (None, ""): - down = legacy - if up in (None, "") or down in (None, ""): - raise ValueError("请填写上破与下破目标价") - up_f = float(up) - down_f = float(down) - if up_f <= down_f: - raise ValueError("上破目标价必须大于下破目标价") - index_px = float(body.get("index_px") or ((up_f + down_f) / 2)) + rr_raw = body.get("oo_profit_rr") + if rr_raw in (None, ""): + rr_raw = body.get("profit_rr") + rr = None + if rr_raw not in (None, ""): + try: + rr = float(rr_raw) + except (TypeError, ValueError) as e: + raise ValueError("盈亏比无效") from e + if rr <= 0: + raise ValueError("盈亏比须大于 0") + + index_px = body.get("index_px") + try: + index_px_f = float(index_px) if index_px not in (None, "") else 0.0 + except (TypeError, ValueError): + index_px_f = 0.0 + leg_a = body.get("leg_a") or {} leg_b = body.get("leg_b") or {} for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)): @@ -1265,13 +1267,38 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: ) if leg.get("premium_paid") is None: raise ValueError(f"缺少 {name} 权利金") - money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px) + money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px_f or None) if money_err: raise ValueError(money_err) + + if rr is not None: + return build_options_options_preview( + profit_rr=rr, + index_px=index_px_f, + leg_a=leg_a, + leg_b=leg_b, + ) + + # 兼容旧上破/下破 + up = body.get("target_price_up") + down = body.get("target_price_down") + legacy = body.get("target_price") + if up in (None, "") and legacy not in (None, ""): + up = legacy + if down in (None, "") and legacy not in (None, ""): + down = legacy + if up in (None, "") or down in (None, ""): + raise ValueError("请填写盈亏比(相对权利金,默认2)") + up_f = float(up) + down_f = float(down) + if up_f <= down_f: + raise ValueError("上破目标价必须大于下破目标价") + if index_px_f <= 0: + index_px_f = (up_f + down_f) / 2 return build_options_options_preview( target_price_up=up_f, target_price_down=down_f, - index_px=index_px, + index_px=index_px_f, leg_a=leg_a, leg_b=leg_b, ) diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index de47b03..c0ccb21 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -213,7 +213,7 @@

账户:两腿都在期权账户。可用预算 = min(交易 USDC × 对冲缓冲 {{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}, 单笔预算);可在 env「对冲预算缓冲比例」改。

下单:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。

-

板块:左填上破/下破与张数模式(同张数/做多/做空);右 T 型选腿。两腿仅允许平值或虚值(禁实值)。「全平」= 盈利腿平后清另一腿;「到期平」= 另一腿持有至到期。

+

板块:左填盈亏比(相对权利金,默认 2=盈利 2 倍权利金)与张数模式(同张数/做多/做空);右 T 型选腿。两腿仅允许平值或虚值(禁实值)。中途浮盈达盈亏比→两腿全平;不达标→等到期。「全平/到期平」仅兼容旧上破下破计划残腿处理。

@@ -221,8 +221,7 @@
- - + 指数 —
@@ -406,4 +405,4 @@
- + diff --git a/lib/options/templates/options_review_panel.html b/lib/options/templates/options_review_panel.html index f67311b..afd0aa3 100644 --- a/lib/options/templates/options_review_panel.html +++ b/lib/options/templates/options_review_panel.html @@ -416,4 +416,4 @@ - + diff --git a/tests/test_hedge_plan_calc.py b/tests/test_hedge_plan_calc.py index b3a4836..f9769a2 100644 --- a/tests/test_hedge_plan_calc.py +++ b/tests/test_hedge_plan_calc.py @@ -102,8 +102,7 @@ class TestHedgePlanCalc(unittest.TestCase): a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} p = build_options_options_preview( - target_price_up=3500, - target_price_down=3000, + profit_rr=2, index_px=3200, leg_a=a, leg_b=b, @@ -111,11 +110,11 @@ class TestHedgePlanCalc(unittest.TestCase): self.assertEqual(p["summary"]["premium_paid"], 10) self.assertTrue(p["summary"]["expiry_is_loss"]) self.assertEqual(p["summary"]["rr_risk_premium"], 10) - self.assertIsNotNone(p["summary"]["rr_at_up"]) - self.assertAlmostEqual(p["summary"]["rr_at_up"], p["summary"]["at_target_up_total"] / 10, places=4) - self.assertEqual(len(p["scenarios"]), 4) - self.assertEqual(p["scenarios"][0]["id"], "target_up") - self.assertEqual(p["scenarios"][1]["id"], "target_down") + self.assertEqual(p["summary"]["oo_profit_rr"], 2) + self.assertAlmostEqual(p["summary"]["target_profit"], 20.0, places=4) + self.assertEqual(len(p["scenarios"]), 3) + self.assertEqual(p["scenarios"][0]["id"], "rr_target") + self.assertEqual(p["scenarios"][1]["id"], "expiry_flat") def test_oo_legacy_single_target_still_works(self): a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} @@ -124,6 +123,21 @@ class TestHedgePlanCalc(unittest.TestCase): self.assertEqual(p["target_price_up"], 3500) self.assertEqual(p["target_price_down"], 3500) + def test_oo_legacy_up_down_rr_fields(self): + a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} + b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} + p = build_options_options_preview( + target_price_up=3500, + target_price_down=3000, + index_px=3200, + leg_a=a, + leg_b=b, + ) + self.assertIsNotNone(p["summary"]["rr_at_up"]) + self.assertAlmostEqual(p["summary"]["rr_at_up"], p["summary"]["at_target_up_total"] / 10, places=4) + self.assertEqual(p["scenarios"][0]["id"], "target_up") + self.assertEqual(p["scenarios"][1]["id"], "target_down") + def test_perp_short_pnl(self): self.assertEqual( perp_pnl(direction="short", entry=100, exit_px=90, contracts=1, contract_size=1), diff --git a/tests/test_hedge_plan_history_stats.py b/tests/test_hedge_plan_history_stats.py index ea451b0..d56f558 100644 --- a/tests/test_hedge_plan_history_stats.py +++ b/tests/test_hedge_plan_history_stats.py @@ -155,6 +155,32 @@ class TestHedgeHistoryStats(unittest.TestCase): self.assertEqual(targets["ETH-USD_UM-260719-1850-P"]["target_index"], 1800) self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan") + def test_active_options_targets_rr_mode_marks_managed(self): + conn = _mem() + pid = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "active", + "underlying": "ETH", + "oo_profit_rr": 2, + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_a", + "inst_id": "ETH-USD_UM-260719-1890-C", + "opt_type": "C", + "status": "open", + }, + ) + targets = active_options_targets_by_inst(conn) + self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan") + self.assertIsNone(targets["ETH-USD_UM-260719-1890-C"]["target_index"]) + self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["oo_profit_rr"], 2.0) + if __name__ == "__main__": unittest.main()