From eca6d091e942f9394882218e253fa77fe97540a6 Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 9 Aug 2026 09:27:42 +0800 Subject: [PATCH] feat(hedge): option-primary watch entry with leverage gate Start strategy arms a watching plan instead of opening immediately; list filters by leverage; type is a dropdown defaulting to OTM. Co-authored-by: Cursor --- docs/对冲计划-以期权为主.md | 6 +- lib/common/static/hedge_plan.js | 131 +++++++++++++- lib/common/static/instance_theme.css | 18 +- lib/hedge_plan/hedge_plan_db.py | 29 +++- lib/hedge_plan/hedge_plan_monitor_lib.py | 125 +++++++++++++- .../hedge_plan_option_primary_lib.py | 104 ++++++++++- lib/hedge_plan/hedge_plan_orders_lib.py | 8 +- lib/hedge_plan/hedge_plan_register.py | 161 +++++++++++++++++- .../templates/hedge_plan_panel.html | 20 +-- tests/test_hedge_plan_option_primary.py | 45 ++++- 10 files changed, 608 insertions(+), 39 deletions(-) diff --git a/docs/对冲计划-以期权为主.md b/docs/对冲计划-以期权为主.md index 1f1f719..6e57e85 100644 --- a/docs/对冲计划-以期权为主.md +++ b/docs/对冲计划-以期权为主.md @@ -10,9 +10,9 @@ | UI 做多 | 永续多 + 买 Put | 买 Call + 永续空 | | UI 做空 | 永续空 + 买 Call | 买 Put + 永续多 | | 左卡 | 开仓价 / 张数 / TP / SL | 资金与杠杆 / 选约条件 / 出场条件 三组 | -| 右卡 | 上永续行情 · 下期权链 | 同上;期权表含杠杆(指数÷卖一),按类型自动匹配 | -| 选约 | 仅实值/平值 | 实/平/虚 + 间隔 + 杠杆门 | -| 开仓 | 受 `HEDGE_PLAN_OPEN_ORDER` | **强制先期权**,成交后**立即市价**开永续(**不挂**交易所 TP/SL) | +| 右卡 | 上永续行情 · 下期权链 | 同上;仅展示间隔+类型+杠杆达标候选 | +| 选约 | 仅实值/平值 | 类型下拉(默认虚值)+间隔+杠杆门槛 | +| 开仓 | 受 `HEDGE_PLAN_OPEN_ORDER` | **策略启动=盯盘**(status=`watching`),达标后才先期权后市价永续(**不挂**交易所 TP/SL) | | 出场 | 交易所 TP/SL | 相对 K 的点数目标分叉 | ## 2. 左卡默认 diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index de1a7ff..0c83beb 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -22,7 +22,7 @@ tab: pickDefaultTab(), mode: showPerp ? "perp_options" : showOo ? "options_options" : "perp_options", underlying: root.getAttribute("data-default-underly") || "ETH", - moneyFilter: "itm", // 永期锁定:实值+平值 + moneyFilter: root.getAttribute("data-option-primary") !== "0" ? "otm" : "itm", ooMoneyFilter: "atm_otm", // 期期锁定:平值+虚值 ooRecommend: null, // atm_straddle | double_otm | null ooStrikeExpandAll: false, // 默认 Call/Put 各 3 档 @@ -149,17 +149,35 @@ return fmt(v, 2) + ":1"; } + /** 列表/盯盘候选杠杆门槛(与后端 effective_min_opt_leverage 对齐). */ + function optionPrimaryMinLev() { + const minLev = numInput("hp-opt-leverage", opMoneyKind() === "otm" ? 200 : 100); + if (!(minLev > 0)) return 0; + if (opMoneyKind() === "otm") return Math.max(minLev, 180); + return minLev; + } + + function optionPrimaryLevOk(c) { + const idx = indexPx(); + const ask = Number(c && c.ask); + const floor = optionPrimaryMinLev(); + if (!(floor > 0)) return true; + if (!(idx > 0) || !(ask > 0)) return false; + return idx / ask >= floor - 1e-9; + } + function matchesMoneyFilter(c) { const f = state.moneyFilter || "itm"; const m = (c.moneyness || "").toLowerCase(); if (!isOptionPrimary() && f === "otm") return false; - // 列表只按间隔+虚实值;期权杠杆仅启动/计算时由后端校验 + // 列表:间隔+虚实值+杠杆门槛(达标才显示;启动盯盘后监控同样门槛) if (isOptionPrimary()) { const idx = indexPx(); const interval = numInput("hp-strike-interval", 15); if (idx && interval > 0 && Math.abs(Number(c.strike) - idx) > interval + 1e-9) { return false; } + if (!optionPrimaryLevOk(c)) return false; } if (f === "itm") return m === "itm" || m === "atm"; if (f === "atm") return m === "atm"; @@ -315,6 +333,7 @@ const dirShort = document.querySelector('.hp-po-dir[data-dir="short"]'); if (dirLong) dirLong.title = on ? "做多=买Call+永续空" : "做多永续"; if (dirShort) dirShort.title = on ? "做空=买Put+永续多" : "做空永续"; + syncPoActionBtn(); } function hoursFromExpMs(expMs) { @@ -372,6 +391,10 @@ } function syncMoneyUI() { + const moneySel = $("hp-money-select"); + if (moneySel && isOptionPrimary()) { + moneySel.value = state.moneyFilter === "otm" ? "otm" : state.moneyFilter === "atm" ? "atm" : "itm"; + } document.querySelectorAll(".hp-money-btn").forEach(function (b) { const on = b.getAttribute("data-money") === state.moneyFilter; b.classList.toggle("active", on); @@ -386,6 +409,18 @@ }); } + function syncPoActionBtn() { + const btn = $("hp-preview-btn"); + if (!btn) return; + if (isOptionPrimary()) { + btn.textContent = "策略启动"; + btn.title = "按参数启动盯盘;杠杆/间隔达标后自动开仓(非现场开)"; + } else { + btn.textContent = "计算"; + btn.title = "情景测算后再启动"; + } + } + function syncOoRecommendUI() { const cur = state.ooRecommend || ""; document.querySelectorAll(".hp-oo-recommend-btn").forEach(function (b) { @@ -1097,7 +1132,7 @@ return; } if (isOptionPrimary() && !matchesMoneyFilter(c)) { - alert("不符合当前间隔/虚实值过滤"); + alert("不符合当前间隔/虚实值/杠杆门槛"); return; } state.selected = c; @@ -1149,7 +1184,8 @@ sameType.length + " 档)·检查间隔" + (interval != null ? "≤" + interval : "") + - "点/虚实值"; + "点/虚实值/杠杆≥" + + optionPrimaryMinLev(); } else if (!sameType.length) { hint = "该到期无 " + @@ -1741,9 +1777,21 @@ renderListStrikes(); }); }); + if ($("hp-money-select")) { + $("hp-money-select").addEventListener("change", function () { + const m = $("hp-money-select").value || "otm"; + state.moneyFilter = m === "otm" ? "otm" : m === "atm" ? "atm" : "itm"; + state.opLevTouched = false; + state.opRatioTouched = false; + applyOpDefaultsFromMoney(true); + syncMoneyUI(); + renderListStrikes(); + }); + } if ($("hp-opt-leverage")) { $("hp-opt-leverage").addEventListener("input", function () { state.opLevTouched = true; + if (isOptionPrimary()) renderListStrikes(); }); } if ($("hp-opt-perp-ratio")) { @@ -1940,7 +1988,8 @@ if ($("hp-preview-btn")) $("hp-preview-btn").addEventListener("click", function () { state.mode = "perp_options"; - void runPreview(); + if (isOptionPrimary()) void startOptionPrimaryWatch(); + else void runPreview(); }); if ($("hp-preview-btn-oo")) $("hp-preview-btn-oo").addEventListener("click", function () { @@ -2036,6 +2085,14 @@ } function activeTargetLabel(p) { + if (p.plan_type === "perp_options" && (p.option_primary == 1 || p.option_primary === true || Number(p.option_primary) === 1)) { + return ( + "期权K±" + + fmt(p.option_target_points, 0) + + " · 永续K±" + + fmt(p.perp_target_points, 0) + ); + } if (p.plan_type === "perp_options") { return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl); } @@ -2043,6 +2100,9 @@ } function activeStatusLabel(p) { + if ((p.status || "") === "watching") { + return '盯盘中'; + } if ((p.status || "") === "partial") { return '半腿待补'; } @@ -2422,6 +2482,65 @@ } } + async function startOptionPrimaryWatch() { + try { + const optPts = numInput("hp-opt-target-pts", NaN); + const perpPts = numInput("hp-perp-target-pts", NaN); + const prem = numInput("hp-premium-budget", 0); + const optLev = numInput("hp-opt-leverage", 200); + if (!(prem > 0)) throw new Error("请填写权利金预算"); + if (!(optPts > 0) || !(perpPts > 0)) throw new Error("请填写期权/永续目标位点数(须大于0)"); + if (!(optLev > 0)) throw new Error("请填写期权杠杆门槛"); + if (!(state.market && state.market.exchange_symbol)) throw new Error("永续行情未就绪,请先刷新"); + if (!state.canStart) { + throw new Error("当前不可启动(门禁未满足),请查看上方提示"); + } + const msg = + "确认启动盯盘?\n" + + "类型 " + + (opMoneyKind() === "otm" ? "虚值" : opMoneyKind() === "atm" ? "平值" : "实/平") + + " · 间隔 " + + numInput("hp-strike-interval", 15) + + " · 杠杆≥" + + optionPrimaryMinLev() + + "\n达标后自动开仓(非现场立即开)"; + if (!window.confirm(msg)) return; + const body = { + plan_type: "perp_options", + option_primary: true, + watch_entry: 1, + underlying: state.underlying, + direction: getDirection(), + exchange_symbol: state.market.exchange_symbol, + contract_size: state.market.contract_size || 0.01, + index_px: indexPx() || Number(state.market.mark || 0), + entry: indexPx() || Number(state.market.mark || 0), + premium_budget: prem, + option_perp_ratio: numInput("hp-opt-perp-ratio", 4), + option_target_points: optPts, + perp_target_points: perpPts, + strike_interval: numInput("hp-strike-interval", 15), + min_option_hours: numInput("hp-min-hours", 36), + option_leverage: optLev, + leverage: numInput("hp-perp-leverage", 100), + moneyness: opMoneyKind(), + }; + const d = await apiJson("/api/hedge-plan/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + setGateLine(d.gates); + alert((d.msg || "已启动盯盘") + (d.plan_id ? "\n计划 #" + d.plan_id : "")); + state.tab = "active"; + syncTabUI(); + void loadActivePlans(); + void loadGates(); + } catch (e) { + alert(e.message || String(e)); + } + } + async function startPlan(planType, fromPreviewModal) { const isOo = planType === "options_options"; const startBtn = $("hp-preview-start"); @@ -2567,7 +2686,7 @@ await loadChain(); } catch (e) { const tbody = $("hp-strike-tbody"); - if (tbody) tbody.innerHTML = '' + (e.message || e) + ""; + if (tbody) tbody.innerHTML = '' + (e.message || e) + ""; } } diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index ceb377d..56bd334 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3515,22 +3515,20 @@ html[data-theme="light"] .opt-be-dist-down { grid-template-columns: repeat(3, minmax(0, 1fr)); } .hedge-plan-page-wrap .hp-po-fields--select { - grid-template-columns: minmax(72px, 0.9fr) minmax(72px, 0.9fr) minmax(0, 1.6fr) minmax(72px, 0.9fr); + grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: end; } .hedge-plan-page-wrap .hp-po-field--type { min-width: 0; } -.hedge-plan-page-wrap .hp-po-type-seg { - display: flex; - flex-wrap: nowrap; - gap: 4px; +.hedge-plan-page-wrap .hp-po-field--type select { + width: 100%; + min-width: 0; + box-sizing: border-box; } -.hedge-plan-page-wrap .hp-po-type-seg .hp-money-btn { - flex: 1 1 0; - padding: 4px 6px; - font-size: 0.72rem; - white-space: nowrap; +.hedge-plan-page-wrap .hp-plan-watching { + color: #fbbf24; + font-weight: 650; } @media (max-width: 720px) { .hedge-plan-page-wrap .hp-po-fields--capital, diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py index f108791..299efa6 100644 --- a/lib/hedge_plan/hedge_plan_db.py +++ b/lib/hedge_plan/hedge_plan_db.py @@ -83,6 +83,7 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None: _ensure_column(conn, "hedge_plans", "strike_interval", "REAL") _ensure_column(conn, "hedge_plans", "min_option_hours", "REAL") _ensure_column(conn, "hedge_plans", "option_moneyness", "TEXT") + _ensure_column(conn, "hedge_plans", "option_leverage", "REAL") _ensure_column(conn, "hedge_plans", "perp_direction", "TEXT") _ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL") @@ -99,15 +100,19 @@ def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}") +_ACTIVE_STATUSES = ("opening", "active", "partial", "watching") + + def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int: + statuses = ",".join(f"'{s}'" for s in _ACTIVE_STATUSES) if plan_type: row = conn.execute( - "SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial') AND plan_type=?", + f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses}) AND plan_type=?", (plan_type,), ).fetchone() else: row = conn.execute( - "SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial')" + f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses})" ).fetchone() return int((row["c"] if row else 0) or 0) @@ -200,7 +205,7 @@ def delete_plan(conn: sqlite3.Connection, plan_id: int) -> dict[str, Any]: if not plan: return {"ok": False, "msg": "计划不存在"} st = str(plan.get("status") or "") - if st in ("opening", "active", "partial"): + if st in ("opening", "active", "partial", "watching"): return {"ok": False, "msg": "进行中的计划不可删除,请先结束"} conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),)) conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),)) @@ -236,7 +241,23 @@ def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]]) legs = get_plan_legs(conn, int(p["id"])) row = dict(p) row["legs"] = legs - row["contracts_summary"] = legs_contract_summary(legs) + summary = legs_contract_summary(legs) + if str(p.get("status") or "") == "watching" and (not legs or summary == "—"): + money = str(p.get("option_moneyness") or "otm") + money_lab = {"itm": "实/平", "atm": "平值", "otm": "虚值"}.get(money, money) + parts = [f"盯盘·{money_lab}"] + try: + if p.get("strike_interval") not in (None, ""): + parts.append(f"间隔{float(p.get('strike_interval')):g}") + except (TypeError, ValueError): + pass + try: + if p.get("option_leverage") not in (None, ""): + parts.append(f"杠杆≥{float(p.get('option_leverage')):g}") + except (TypeError, ValueError): + pass + summary = "·".join(parts) + row["contracts_summary"] = summary row["missing_leg"] = missing_leg_role(legs) out.append(row) return out diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py index fa6b37c..53addea 100644 --- a/lib/hedge_plan/hedge_plan_monitor_lib.py +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -133,7 +133,8 @@ def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]: from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables init_hedge_plan_tables(conn) - plans = list_plans(conn, status="active", limit=40) + plans = list_plans(conn, status="watching", limit=20) + plans.extend(list_plans(conn, status="active", limit=40)) # partial:裸永续/半腿也需侦测永续 TP/SL plans.extend(list_plans(conn, status="partial", limit=20)) seen: set[int] = set() @@ -227,6 +228,8 @@ def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[ from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary if is_option_primary(plan): + if str(plan.get("status") or "") == "watching": + return _tick_po_option_primary_watching(cfg, conn, plan) # 期权为主:半平重试 → 到期 → 目标位分叉 r = _tick_po_option_primary_pending(cfg, conn, plan, legs) if r: @@ -251,6 +254,126 @@ def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[ return None +def _tick_po_option_primary_watching( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any] +) -> Optional[dict[str, Any]]: + """盯盘:链上出现杠杆/间隔达标合约后自动开仓.""" + import json + import os + + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + pick_option_primary_candidate, + size_from_premium, + ) + from lib.hedge_plan.hedge_plan_orders_lib import execute_perp_options_start + from lib.hedge_plan.hedge_plan_register import _activate_watching_po + + build_chain = cfg.get("build_option_chain") + ex = cfg.get("exchange_options") + if not callable(build_chain) or ex is None: + return None + body0: dict[str, Any] = {} + try: + raw = plan.get("preview_json") or "" + blob = json.loads(raw) if raw else {} + body0 = dict(blob.get("start_body") or blob or {}) + except Exception: + body0 = {} + uly = str(plan.get("underlying") or body0.get("underlying") or "ETH").upper() + direction = str(plan.get("direction") or body0.get("direction") or "long").lower() + money = str(plan.get("option_moneyness") or body0.get("moneyness") or "otm").lower() + interval = plan.get("strike_interval") + if interval in (None, ""): + interval = body0.get("strike_interval", 15) + min_h = plan.get("min_option_hours") + if min_h in (None, ""): + min_h = body0.get("min_option_hours", 36) + opt_lev = plan.get("option_leverage") + if opt_lev in (None, ""): + opt_lev = body0.get("option_leverage") + try: + chain = build_chain( + ex, + uly, + max_dte_days=float(cfg.get("chain_max_dte") or 14), + itm_only=False, + itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"), + ) + except Exception as e: + update_plan(conn, int(plan["id"]), note=f"盯盘拉链失败: {e}"[:500]) + return None + cand = pick_option_primary_candidate( + chain, + direction=direction, + moneyness=money, + strike_interval=interval, + min_hours=min_h, + min_opt_leverage=opt_lev, + ) + if not cand: + return None + ask = float(cand.get("ask") or 0) + ct = float(cand.get("ct_mult") or body0.get("ct_mult") or 0.01) + sized = size_from_premium( + premium_budget=float(plan.get("premium_budget") or body0.get("premium_budget") or 0), + ask=ask, + ct_mult=ct, + ratio=float(plan.get("option_perp_ratio") or body0.get("option_perp_ratio") or 2), + contract_size=float(body0.get("contract_size") or 0.01), + ) + if not sized.get("ok"): + update_plan(conn, int(plan["id"]), note=f"盯盘定仓失败: {sized.get('msg')}"[:500]) + return None + idx = float(cand.get("index_px") or chain.get("index_px") or 0) + body = dict(body0) + body.update( + { + "plan_type": "perp_options", + "option_primary": True, + "watch_entry": 0, + "underlying": uly, + "direction": direction, + "moneyness": money, + "opt_inst_id": cand.get("inst_id"), + "opt_type": cand.get("opt_type"), + "strike": cand.get("strike"), + "ask": ask, + "ct_mult": ct, + "sheets": sized["sheets"], + "contracts": sized["contracts"], + "eth_qty": sized.get("eth_qty"), + "index_px": idx, + "entry": idx, + "hours_to_expiry": cand.get("hours_to_expiry"), + "strike_interval": interval, + "min_option_hours": min_h, + "option_leverage": opt_lev, + "option_perp_ratio": plan.get("option_perp_ratio") or body0.get("option_perp_ratio"), + "option_target_points": plan.get("option_target_points") or body0.get("option_target_points"), + "perp_target_points": plan.get("perp_target_points") or body0.get("perp_target_points"), + "premium_budget": plan.get("premium_budget") or body0.get("premium_budget"), + "leverage": plan.get("leverage") or body0.get("leverage") or 100, + "exchange_symbol": body0.get("exchange_symbol") or f"{uly}-USDT-SWAP", + "contract_size": body0.get("contract_size") or 0.01, + } + ) + dry = str(os.getenv("HEDGE_PLAN_DRY_RUN") or "").strip().lower() in ("1", "true", "yes", "on") + out = execute_perp_options_start(cfg, body, dry_run=dry, persist=None) + if not out.get("ok"): + update_plan(conn, int(plan["id"]), note=f"盯盘开仓未成: {out.get('msg')}"[:500]) + return {"plan_id": plan["id"], "watching_open": False, "msg": out.get("msg")} + if dry: + update_plan(conn, int(plan["id"]), note=f"dry_run命中 {cand.get('inst_id')}"[:500]) + return {"plan_id": plan["id"], "watching_open": True, "dry_run": True, "inst_id": cand.get("inst_id")} + _activate_watching_po(cfg, conn, int(plan["id"]), out, body) + return { + "plan_id": plan["id"], + "watching_open": True, + "inst_id": cand.get("inst_id"), + "leverage": cand.get("leverage"), + } + + def _tick_po_option_primary_pending( cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] ) -> Optional[dict[str, Any]]: diff --git a/lib/hedge_plan/hedge_plan_option_primary_lib.py b/lib/hedge_plan/hedge_plan_option_primary_lib.py index 1f9b577..ec82129 100644 --- a/lib/hedge_plan/hedge_plan_option_primary_lib.py +++ b/lib/hedge_plan/hedge_plan_option_primary_lib.py @@ -298,6 +298,43 @@ def validate_option_primary_moneyness( return None +def validate_option_primary_watch(body: dict[str, Any]) -> Optional[str]: + """盯盘启动校验:只要参数,不要求已选具体合约.""" + need = ( + "direction", + "exchange_symbol", + "premium_budget", + "option_target_points", + "perp_target_points", + "option_perp_ratio", + "option_leverage", + ) + for k in need: + if body.get(k) in (None, ""): + return f"缺少字段: {k}" + try: + if float(body["premium_budget"]) <= 0: + return "权利金须大于 0" + if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0: + return "目标位点数须大于 0" + if float(body["option_perp_ratio"]) <= 0: + return "期权永续比例须大于 0" + if float(body["option_leverage"]) <= 0: + return "期权杠杆须大于 0" + lev_perp = _sf(body.get("leverage")) + if lev_perp is not None and lev_perp <= 0: + return "永续杠杆须大于 0" + except (TypeError, ValueError): + return "数值字段无效" + direction = str(body.get("direction") or "").strip().lower() + if direction not in ("long", "short"): + return "方向须为 long 或 short" + moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower() + if moneyness not in ("itm", "atm", "otm"): + return "期权类型(实/平/虚)无效" + return None + + def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]: need = ( "direction", @@ -337,7 +374,7 @@ def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]: want = opt_type_for_view(direction) if opt_type != want: return f"以期权为主时做{'多' if direction == 'long' else '空'}须用 {'Call' if want == 'C' else 'Put'}" - moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "atm").strip().lower() + moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower() from lib.hedge_plan.hedge_plan_moneyness_lib import parse_strike_from_inst strike = body.get("strike") @@ -357,6 +394,71 @@ def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]: ) +def pick_option_primary_candidate( + chain: dict[str, Any], + *, + direction: str, + moneyness: str = "otm", + strike_interval: Any = DEFAULT_STRIKE_INTERVAL, + min_hours: Any = DEFAULT_MIN_HOURS, + min_opt_leverage: Any = None, +) -> Optional[dict[str, Any]]: + """从期权链挑最近达标合约(间隔+虚实值+杠杆门).""" + from lib.hedge_plan.hedge_plan_moneyness_lib import classify_moneyness + + want = opt_type_for_view(direction) + m_want = (moneyness or "otm").strip().lower() + interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL) + min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS) + try: + idx = float(chain.get("index_px") or 0) + except (TypeError, ValueError): + idx = 0.0 + if idx <= 0: + return None + + best: Optional[dict[str, Any]] = None + best_dist: Optional[float] = None + for exp in chain.get("expiries") or []: + h = hours_to_expiry_from_ms(exp.get("exp_time")) + if min_h > 0 and h is not None and h < min_h: + continue + for c in exp.get("contracts") or []: + if str(c.get("opt_type") or "").upper() != want: + continue + try: + k = float(c.get("strike") or 0) + ask = float(c.get("ask") or 0) + except (TypeError, ValueError): + continue + if k <= 0 or ask <= 0: + continue + if interval > 0 and abs(k - idx) > interval + 1e-9: + continue + m_got = classify_moneyness(opt_type=want, strike=k, index_px=idx) + if m_want == "itm" and m_got not in ("itm", "atm"): + continue + if m_want == "atm" and m_got != "atm": + continue + if m_want == "otm" and m_got == "itm": + continue + min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else (m_got or "atm"), min_opt_leverage) + if min_lev > 0 and idx / ask < min_lev - 1e-9: + continue + dist = abs(k - idx) + if best is None or best_dist is None or dist < best_dist: + best = { + **dict(c), + "hours_to_expiry": h, + "exp_time": exp.get("exp_time"), + "moneyness": m_got, + "index_px": idx, + "leverage": round(idx / ask, 1), + } + best_dist = dist + return best + + def build_option_primary_preview(body: dict[str, Any]) -> dict[str, Any]: """情景:期权目标 / 永续目标粗估净利.""" view = str(body.get("direction") or "long").lower() diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index 198d5f9..d0180d4 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -1078,6 +1078,12 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: ) if is_option_primary(body): + from lib.hedge_plan.hedge_plan_option_primary_lib import validate_option_primary_watch + + # 以期权为主默认盯盘启动(非现场开仓);显式 watch_entry=0 才走即开校验 + watch = body.get("watch_entry") + if watch in (None, "", True, 1, "1", "true", "yes", "on"): + return validate_option_primary_watch(body) return validate_option_primary_start(body) need = ("direction", "entry", "tp", "sl", "contracts", "opt_inst_id", "sheets", "exchange_symbol") for k in need: @@ -1289,7 +1295,7 @@ def execute_manual_end_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> dic if not plan: return {"ok": False, "msg": "计划不存在"} st = str(plan.get("status") or "") - if st not in ("opening", "active", "partial"): + if st not in ("opening", "active", "partial", "watching"): return {"ok": False, "msg": f"当前状态 {st or '—'} 不可结束"} notes = reconcile_unfilled_option_legs(cfg, conn, int(plan_id)) diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index cd32cab..02bbe22 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -387,6 +387,134 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any conn.close() +def _persist_po_watching(cfg: dict[str, Any], body: dict[str, Any]) -> int: + """以期权为主:只落库盯盘计划,不下单.""" + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_plan + from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + view = str(body.get("direction") or "long") + money = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower() + plan_id = insert_plan( + conn, + { + "plan_type": "perp_options", + "status": "watching", + "underlying": str(body.get("underlying") or "ETH").upper(), + "direction": view, + "entry_mark": float(body.get("index_px") or body.get("entry") or 0) or None, + "tp": 0, + "sl": 0, + "sizing_mode_at_open": None, + "perp_size": None, + "margin": None, + "leverage": float(body.get("leverage") or 100), + "premium_total": 0, + "preview_json": _start_body_json(body), + "close_reason": None, + "opened_at": None, + "note": "盯盘中:等待杠杆/间隔达标后自动开仓", + "option_primary": 1, + "perp_direction": perp_direction_for_view(view), + "option_target_points": float(body.get("option_target_points") or 0), + "perp_target_points": float(body.get("perp_target_points") or 0), + "option_perp_ratio": float(body.get("option_perp_ratio") or 0), + "premium_budget": float(body.get("premium_budget") or 0), + "strike_interval": float(body.get("strike_interval") or 15), + "min_option_hours": float(body.get("min_option_hours") or 36), + "option_moneyness": money, + "option_leverage": float(body.get("option_leverage") or 0), + }, + ) + conn.commit() + return plan_id + finally: + conn.close() + + +def _activate_watching_po( + cfg: dict[str, Any], + conn: Any, + plan_id: int, + result: dict[str, Any], + body: dict[str, Any], +) -> None: + """盯盘命中后:写入腿并把 watching → active/partial.""" + from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, insert_leg, update_plan + from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start + from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view + + is_partial = bool(result.get("partial")) + missing = str(result.get("missing_leg") or "") if is_partial else "" + opt = result.get("option") or {} + perp = result.get("perp") or {} + if is_partial: + opt_ok = missing != "option_hedge" and bool(result.get("option")) + perp_ok = missing != "perp" and bool(result.get("perp")) + else: + opt_ok = True + perp_ok = True + premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0 + view = str(body.get("direction") or "long") + perp_dir = ( + str((perp or {}).get("direction") or "") + or perp_direction_for_view(view) + ) + update_plan( + conn, + int(plan_id), + status="partial" if is_partial else "active", + entry_mark=float(body.get("entry") or body.get("index_px") or 0) or None, + perp_size=float((perp or {}).get("contracts") or body.get("contracts") or 0), + leverage=float(body.get("leverage") or 100), + premium_total=premium, + preview_json=_start_body_json(body, missing or None), + close_reason="partial_fail" if is_partial else None, + opened_at=result.get("opened_at"), + note=(result.get("msg") or "")[:500] if is_partial else "盯盘达标已开仓", + perp_direction=perp_dir, + ) + insert_leg( + conn, + { + "plan_id": int(plan_id), + "leg_role": "perp", + "symbol": str(body.get("exchange_symbol") or ""), + "side": perp_dir, + "size": float((perp or {}).get("contracts") or body.get("contracts") or 0), + "avg_open": float(body.get("entry") or 0) if perp_ok else None, + "status": "open" if perp_ok else "pending", + "exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at") if perp_ok else None, + }, + ) + insert_leg( + conn, + { + "plan_id": int(plan_id), + "leg_role": "option_hedge", + "inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""), + "opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""), + "strike": (opt or {}).get("strike") or body.get("strike"), + "side": "buy", + "size": float((opt or {}).get("sheets") or body.get("sheets") or 1), + "avg_open": float((opt or {}).get("ask") or body.get("ask") or 0) if opt_ok else None, + "premium": premium if opt_ok else 0, + "ct_mult": float(body.get("ct_mult") or (opt or {}).get("ct_mult") or 0.01), + "status": "open" if opt_ok else "pending", + "exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at") if opt_ok else None, + }, + ) + if not is_partial: + plan = get_plan(conn, int(plan_id)) + legs = get_plan_legs(conn, int(plan_id)) + if plan: + notify_plan_start(cfg, conn, plan, legs) + + def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int: from lib.hedge_plan.hedge_plan_db import ( get_plan, @@ -702,6 +830,33 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: body["leverage"] = int(cfg.get("btc_leverage") or 10) else: body["leverage"] = int(cfg.get("alt_leverage") or 5) + # 以期权为主:策略启动=盯盘,不现场开仓 + if plan_type == "perp_options": + from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary + + watch = body.get("watch_entry") + watch_on = watch in (None, "", True, 1, "1", "true", "yes", "on") + if is_option_primary(body) and watch_on: + if dry_run: + return jsonify( + { + "ok": True, + "dry_run": True, + "watching": True, + "msg": "dry_run:将创建盯盘计划(不落库)", + "gates": gates, + } + ) + plan_id = _persist_po_watching(cfg, body) + return jsonify( + { + "ok": True, + "watching": True, + "plan_id": plan_id, + "msg": "已启动盯盘,杠杆/间隔达标后自动开仓", + "gates": gates, + } + ) if plan_type == "options_options": out = execute_options_options_start( cfg, @@ -904,17 +1059,19 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: try: init_hedge_plan_tables(conn) rows = [] - for status in ("opening", "active", "partial"): + for status in ("watching", "opening", "active", "partial"): rows.extend(list_plans(conn, status=status, limit=80)) rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) for row in rows: + if str(row.get("status") or "") == "watching": + continue try: reconcile_unfilled_option_legs(cfg, conn, int(row["id"])) except Exception: pass # 校正后可能 status 变化,重新拉一遍 rows = [] - for status in ("opening", "active", "partial"): + for status in ("watching", "opening", "active", "partial"): rows.extend(list_plans(conn, status=status, limit=80)) rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) plans = attach_legs_to_plans(conn, rows) diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index b14e8be..c3fe9bf 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -56,7 +56,7 @@

账户:永续腿走合约账户(USDT);期权腿走期权账户(USDC)。两账户分开下单、资金不互通。

模式:在 env HEDGE_PLAN_OPTION_PRIMARY 切换(true=以期权为主 / false=保险模式);标题前标识当前模式。

保险模式:做多配 Put、做空配 Call;左填开仓/止盈止损;仅实值/平值;交易所 TP/SL 出场。

-

以期权为主:做多买 Call+永续空、做空买 Put+永续多;左分资金/选约/出场三组;右侧上永续下期权(含杠杆),按类型自动匹配;开仓先期权后市价永续。

+

以期权为主:填参后点「策略启动」进入盯盘(非现场开仓);杠杆/间隔达标后自动先开期权再市价永续。右侧列表仅展示达标候选。

@@ -116,14 +116,14 @@ 期权间隔 -
+
+ +
- +
@@ -403,4 +403,4 @@ - + diff --git a/tests/test_hedge_plan_option_primary.py b/tests/test_hedge_plan_option_primary.py index 6cefe8f..7505dc5 100644 --- a/tests/test_hedge_plan_option_primary.py +++ b/tests/test_hedge_plan_option_primary.py @@ -9,9 +9,11 @@ from lib.hedge_plan.hedge_plan_option_primary_lib import ( opt_type_for_view, option_bid_liquidity_ok, perp_direction_for_view, + pick_option_primary_candidate, size_from_premium, target_hit, validate_option_primary_start, + validate_option_primary_watch, ) from lib.hedge_plan.hedge_plan_orders_lib import build_po_path_plan, validate_start_body @@ -93,9 +95,25 @@ class TestOptionPrimary(unittest.TestCase): self.assertEqual(path[1]["direction"], "short") self.assertFalse(path[1]["attach_tpsl"]) - def test_validate_option_primary_start(self): + def test_validate_option_primary_watch_and_start(self): + watch_body = { + "option_primary": True, + "watch_entry": 1, + "direction": "long", + "exchange_symbol": "ETH/USDT:USDT", + "premium_budget": 100, + "option_target_points": 50, + "perp_target_points": 30, + "option_perp_ratio": 4, + "option_leverage": 200, + "moneyness": "otm", + } + self.assertIsNone(validate_option_primary_watch(watch_body)) + self.assertIsNone(validate_start_body("perp_options", watch_body)) + body = { "option_primary": True, + "watch_entry": 0, "direction": "long", "contracts": 1, "opt_inst_id": "ETH-USD-260831-1900-C", @@ -120,6 +138,31 @@ class TestOptionPrimary(unittest.TestCase): bad = dict(body, opt_type="P") self.assertIsNotNone(validate_start_body("perp_options", bad)) + def test_pick_candidate_respects_leverage(self): + chain = { + "index_px": 1900, + "expiries": [ + { + "exp_time": 9_999_999_999_999, + "contracts": [ + {"inst_id": "LOW", "opt_type": "C", "strike": 1920, "ask": 20, "ct_mult": 0.01}, + {"inst_id": "OK", "opt_type": "C", "strike": 1925, "ask": 8, "ct_mult": 0.01}, + ], + } + ], + } + # 1900/20=95 < 200; 1900/8=237.5 ≥ 200 + picked = pick_option_primary_candidate( + chain, + direction="long", + moneyness="otm", + strike_interval=50, + min_hours=1, + min_opt_leverage=200, + ) + self.assertIsNotNone(picked) + self.assertEqual(picked["inst_id"], "OK") + def test_preview_builds_scenarios(self): body = { "direction": "long",