From afe361ce47967cb2c28e2ee9d29d473a0f816fe7 Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 9 Aug 2026 08:29:26 +0800 Subject: [PATCH] feat(hedge): add option-primary mode for perp+options plans Add UI switch for Call+short/Put+long, premium x0.95 sizing, option-first open, and K+/-points exits with fee-aware net PnL. Co-authored-by: Cursor --- docs/审计修复报告-以期权为主-2026-08-09.md | 32 ++ docs/对冲计划-以期权为主.md | 69 +++ docs/对冲计划-选约与虚实值.md | 8 +- lib/common/static/hedge_plan.js | 476 +++++++++++++++--- lib/hedge_plan/hedge_plan_db.py | 11 + lib/hedge_plan/hedge_plan_monitor_lib.py | 423 +++++++++++++++- .../hedge_plan_option_primary_lib.py | 425 ++++++++++++++++ lib/hedge_plan/hedge_plan_orders_lib.py | 191 ++++++- lib/hedge_plan/hedge_plan_register.py | 140 ++++-- .../templates/hedge_plan_panel.html | 58 ++- tests/test_hedge_plan_option_primary.py | 144 ++++++ 11 files changed, 1835 insertions(+), 142 deletions(-) create mode 100644 docs/审计修复报告-以期权为主-2026-08-09.md create mode 100644 docs/对冲计划-以期权为主.md create mode 100644 lib/hedge_plan/hedge_plan_option_primary_lib.py create mode 100644 tests/test_hedge_plan_option_primary.py diff --git a/docs/审计修复报告-以期权为主-2026-08-09.md b/docs/审计修复报告-以期权为主-2026-08-09.md new file mode 100644 index 0000000..369fd61 --- /dev/null +++ b/docs/审计修复报告-以期权为主-2026-08-09.md @@ -0,0 +1,32 @@ +# 审计修复报告 · 永期「以期权为主」(2026-08-09) + +## 范围 + +新增 `option_primary` 子模式(UI 开关 + 后端校验/开仓/监控),保险模式路径保持不变。 + +## 审计发现与处置 + +| 级别 | 问题 | 处置 | +|------|------|------| +| High | 期权已平、永续平仓失败后监控不再重试(双腿均须 open) | 增加 `_tick_po_option_primary_pending`,仅补平永续 | +| High | 双目标触达时期权路径因买一/净利跳过,永续目标永不执行 | 期权路径失败且 `hit_perp` 时 fallthrough 永续目标 | +| High | 两腿仍 open 但期权到期无处理,裸奔永续 | `_tick_po_option_primary_both_expired` 结算期权并平永续 | +| High | 目标点数=0 开仓后易立即触发 | 校验与 `target_hit` 要求点数 **>0** | +| Medium | start 未传 leverage 时被写成 10x | 期权为主缺省杠杆 **100** | +| Medium | 服务端 `moneyness=atm` 未强制 ATM | 文档注明;UI 平值筛选仍严格;间隔门兜底 | +| Medium | 平仓永续盈亏用估价 | 已知;不阻塞平仓,统计近似 | + +## 保险模式回归 + +- `validate_start_body` 非 `option_primary` 仍强制 Put/Call + TP/SL 几何 +- `build_po_path_plan` 仅在 `option_primary` 时翻转永续方向并去掉 attach_tpsl +- `_tick_po` 仅在 `option_primary` 为假时走原 TP/SL 路径 + +## 测试 + +`python -m unittest tests.test_hedge_plan_option_primary tests.test_hedge_plan_orders tests.test_hedge_plan_moneyness -v` — 通过。 + +## 文档 + +- 新增 `docs/对冲计划-以期权为主.md` +- 更新 `docs/对冲计划-选约与虚实值.md` diff --git a/docs/对冲计划-以期权为主.md b/docs/对冲计划-以期权为主.md new file mode 100644 index 0000000..3ca47e4 --- /dev/null +++ b/docs/对冲计划-以期权为主.md @@ -0,0 +1,69 @@ +# 对冲计划 · 永期「以期权为主」 + +> 实现日:2026-08-09 · 在现有永期**保险模式**上增加计划级开关,不新增 `OKX_TRADE_MODE`。 + +## 1. 模式对照 + +| | 保险模式(开关关) | 以期权为主(开关开) | +|--|------------------|-------------------| +| UI 做多 | 永续多 + 买 Put | 买 Call + 永续空 | +| UI 做空 | 永续空 + 买 Call | 买 Put + 永续多 | +| 左卡 | 开仓价 / 张数 / TP / SL | 权利金 / 杠杆 / 比例 / 到期h / 间隔 / 目标点数 | +| 选约 | 仅实值/平值 | 实/平/虚 + 间隔 + 杠杆门 | +| 开仓 | 受 `HEDGE_PLAN_OPEN_ORDER` | **强制先期权**,成交后**立即市价**开永续(**不挂**交易所 TP/SL) | +| 出场 | 交易所 TP/SL | 相对 K 的点数目标分叉 | + +## 2. 左卡默认 + +| 字段 | 默认 | +|------|------| +| 权利金 | 用户填(USDC 预算) | +| 永续杠杆 | 100 | +| 期权杠杆 | 实/平 100;虚 200 | +| 期权:永续比例 | 实/平 2;虚 4 | +| 到期时间(最短 h) | 36 | +| 期权间隔(点) | 15 | +| 期权/永续目标位 | 相对 K 点数,须 **>0** | + +## 3. 定仓 + +``` +usable = 权利金 × 0.95 +eth_qty = floor2(usable / ask) # ETH 名义,两位小数 +sheets = floor(eth_qty / ct_mult) # 整张 +perp_eth = eth_qty / 比例 +contracts = perp_eth / contract_size +``` + +启动前再拉卖一重算;卖一深度不足则缩量。 + +## 4. 出场 + +触达任一目标位(做多 `index ≥ K+N`,做空 `index ≤ K−N`)后立即执行: + +| 触达 | 规则 | +|------|------| +| **期权目标** | 验买一流动性 + **扣费净利 > 0** → 先平期权再平永续 | +| **永续目标** | 市价平永续;期权 `hold_to_expiry` 至到期结算 | + +净利:平仓/卖出手续费**按买入费率**估算(`HEDGE_PLAN_FEE_RATE` / `OKX_TAKER_FEE`,默认 0.0005)。 + +若期权目标因买一/净利未过、但永续目标已触达 → 改走永续目标。 +期权已平永续失败 → `opt_target_perp_pending` 下轮只补平永续。 +两腿仍开但期权到期 → 结算期权并平永续,避免裸奔。 + +## 5. 代码落点 + +| 文件 | 作用 | +|------|------| +| `lib/hedge_plan/hedge_plan_option_primary_lib.py` | 定仓/方向/目标/净利/校验 | +| `hedge_plan_orders_lib.py` | 路径、开平永续、启动前定仓刷新 | +| `hedge_plan_monitor_lib.py` | `_tick_po_option_primary*` | +| `hedge_plan_register.py` / `hedge_plan_db.py` | preview/start/persist 列 | +| `hedge_plan.js` + `hedge_plan_panel.html` | 开关与左右卡 | + +## 6. 测试 + +```bash +python -m unittest tests.test_hedge_plan_option_primary -v +``` diff --git a/docs/对冲计划-选约与虚实值.md b/docs/对冲计划-选约与虚实值.md index d517371..b0170ae 100644 --- a/docs/对冲计划-选约与虚实值.md +++ b/docs/对冲计划-选约与虚实值.md @@ -6,7 +6,8 @@ | 计划类型 | 允许虚实值 | 禁止 | 推荐模板 | |----------|------------|------|----------| -| **永期** `perp_options` | 实值、平值 | **虚值** | 距指数最近的实值/平值(同方向 Put/Call) | +| **永期保险** `perp_options`(开关关) | 实值、平值 | **虚值** | 距指数最近的实值/平值(做多 Put / 做空 Call) | +| **永期以期权为主** `option_primary=1` | 实值、平值、**虚值** | —(间隔+杠杆门) | 做多 Call+永续空 / 做空 Put+永续多;详见 `docs/对冲计划-以期权为主.md` | | **期期** `options_options` | 平值、虚值 | **实值** | 平值跨式(ATM C+P);双虚值(OTM C+P) | 口径与 `lib/options/options_pricing_lib.option_moneyness` 一致:ATM 带 = `max(指数×0.2%, 2U)`。 @@ -43,7 +44,7 @@ | 文案 | 规则说明与 alert 明确禁虚(永期)/禁实(期期) | | 服务端一致 | UI 过滤可绕过时,preview/start 仍会 400 | | 兼容旧 API | 未传 `strike` 时从 `inst_id` 解析;未传 `index_px` 时永期用 `entry`、期期用上下破中点 | -| 未移植 | 仿真净盈亏 15U 离场、固定方向自动轮换到期 — 故意不接,避免与本仓 TP/SL 冲突 | +| 以期权为主 | 见 `docs/对冲计划-以期权为主.md`:点数目标+扣费净利出场(非仿真 15U 固定);保险模式仍不接仿真净盈亏离场 | **已知局限:** @@ -59,7 +60,8 @@ | 客户端选实值期期腿 | 同上 | | 过深实值权利金过贵 / 杠杆过低 | `ITM_MAX_DIST` + 可选 `MIN_OPTION_LEVERAGE` | | 误开实盘 | 既有 `HEDGE_PLAN_LIVE_ORDER` ∩ `LIVE_TRADING_ENABLED` ∩ 全仓(永期)门禁不变 | -| 本改动是否改平仓路径 | **否**;不触碰现有持仓、不改 TP/SL 监控逻辑 | +| 保险模式平仓 | 不变:交易所 TP/SL | +| 以期权为主平仓 | 独立监控分支;不改保险模式路径 | ## 6. 测试 diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index 3ae37b6..bd2147d 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -37,6 +37,9 @@ ooCloseModeEnabled: root.getAttribute("data-oo-close-mode-enabled") !== "0", ooCloseMode: "close_all", direction: "long", + optionPrimary: false, + opLevTouched: false, + opRatioTouched: false, tradingUsdc: null, fundingUsdc: null, tradeBudgetUsdc: null, @@ -147,9 +150,20 @@ } 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; + } + const ask = Number(c.ask || 0); + const minLev = numInput("hp-opt-leverage", f === "otm" ? 200 : 100); + const levFloor = f === "otm" ? Math.max(minLev, 180) : minLev; + if (idx && ask > 0 && levFloor > 0 && idx / ask < levFloor) return false; + } if (f === "itm") return m === "itm" || m === "atm"; if (f === "atm") return m === "atm"; if (f === "otm") return m === "otm"; @@ -226,10 +240,138 @@ return { call: call, put: put }; } + function isOptionPrimary() { + return !!state.optionPrimary; + } + function optTypeForDirection(dir) { + if (isOptionPrimary()) { + return dir === "short" ? "P" : "C"; + } return dir === "short" ? "C" : "P"; } + function opMoneyKind() { + const f = state.moneyFilter || "itm"; + if (f === "otm") return "otm"; + if (f === "atm") return "atm"; + return "itm"; + } + + function applyOpDefaultsFromMoney(force) { + const kind = opMoneyKind(); + const levEl = $("hp-opt-leverage"); + const ratioEl = $("hp-opt-perp-ratio"); + if (levEl && (force || !state.opLevTouched)) { + levEl.value = kind === "otm" ? "200" : "100"; + } + if (ratioEl && (force || !state.opRatioTouched)) { + ratioEl.value = kind === "otm" ? "4" : "2"; + } + } + + function syncOptionPrimaryUI() { + const on = isOptionPrimary(); + document.querySelectorAll(".hp-po-mode").forEach(function (b) { + const v = b.getAttribute("data-option-primary") === "1"; + b.classList.toggle("is-selected", v === on); + b.classList.toggle("active", v === on); + }); + const ins = $("hp-po-fields-insurance"); + const op = $("hp-po-fields-option-primary"); + if (ins) { + ins.classList.toggle("hidden", on); + if (on) ins.setAttribute("hidden", "hidden"); + else ins.removeAttribute("hidden"); + } + if (op) { + op.classList.toggle("hidden", !on); + if (!on) op.setAttribute("hidden", "hidden"); + else op.removeAttribute("hidden"); + } + const otmBtn = document.querySelector(".hp-money-otm"); + if (otmBtn) { + otmBtn.classList.toggle("hidden", !on); + if (!on) otmBtn.setAttribute("hidden", "hidden"); + else otmBtn.removeAttribute("hidden"); + } + if (!on && state.moneyFilter === "otm") { + state.moneyFilter = "itm"; + syncMoneyUI(); + } + if (on) applyOpDefaultsFromMoney(false); + const title = $("hp-po-card-title"); + if (title) title.textContent = on ? "执行参数" : "永续"; + const rightQ = $("hp-po-perp-quote-right"); + if (rightQ) { + rightQ.classList.toggle("hidden", !on); + if (!on) rightQ.setAttribute("hidden", "hidden"); + else rightQ.removeAttribute("hidden"); + } + const dirLong = document.querySelector('.hp-po-dir[data-dir="long"]'); + const dirShort = document.querySelector('.hp-po-dir[data-dir="short"]'); + if (dirLong) dirLong.title = on ? "做多=买Call+永续空" : "做多永续"; + if (dirShort) dirShort.title = on ? "做空=买Put+永续多" : "做空永续"; + } + + function setOptionPrimary(on, forceReload) { + const next = !!on; + const changed = next !== isOptionPrimary(); + state.optionPrimary = next; + if (changed) { + state.opLevTouched = false; + state.opRatioTouched = false; + state.selected = null; + if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; + } + syncOptionPrimaryUI(); + if (!changed && !forceReload) return; + void loadMarket().then(function () { + return loadChain(); + }); + } + + function hoursFromExpMs(expMs) { + const n = Number(expMs); + if (!n || Number.isNaN(n)) return null; + const ms = n < 1e12 ? n * 1000 : n; + return (ms - Date.now()) / 3600000; + } + + function numInput(id, fallback) { + const el = $(id); + const n = Number(el && el.value); + if (Number.isNaN(n)) return fallback; + return n; + } + + function computeOpSizing(ask, ctMult) { + const budget = numInput("hp-premium-budget", 0); + const ratio = numInput("hp-opt-perp-ratio", 2); + const cs = Number((state.market && state.market.contract_size) || 0.01); + const usable = budget * 0.95; + const a = Number(ask || 0); + const ct = Number(ctMult || 0.01); + if (!(budget > 0) || !(a > 0) || !(ct > 0) || !(ratio > 0) || !(cs > 0)) { + return null; + } + let eth = Math.floor((usable / a) * 100 + 1e-12) / 100; + if (!(eth > 0)) return null; + let sheets = Math.floor(eth / ct + 1e-12); + if (!(sheets > 0)) return null; + eth = Math.round(sheets * ct * 100) / 100; + const perpEth = eth / ratio; + const contracts = perpEth / cs; + return { + usable: usable, + eth_qty: eth, + sheets: sheets, + contracts: contracts, + premium_est: a * sheets * ct, + ratio: ratio, + }; + } + function syncUnderlyingUI() { const uly = state.underlying || "ETH"; document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (b) { @@ -728,7 +870,9 @@ "/api/hedge-plan/market?base=" + encodeURIComponent(state.underlying) + "&direction=" + - encodeURIComponent(dir) + encodeURIComponent(dir) + + "&option_primary=" + + (isOptionPrimary() ? "1" : "0") ); state.market = d; setGateLine(d.gates); @@ -738,20 +882,26 @@ const amtPrec = d.amount_precision != null ? Number(d.amount_precision) : 4; const markEl = $("hp-po-mark"); if (markEl) markEl.textContent = "标记 " + fmt(d.mark, 2); + const quoteHtml = + "可用 " + + fmt(d.available_usdt, 2) + + " USDT · 卖一 " + + fmt(d.ask, 2) + + " · 买一 " + + fmt(d.bid, 2) + + " · 面值 " + + fmt(d.contract_size, 4) + + " · 精度 " + + amtPrec + + " 位" + + (d.perp_direction + ? " · 永续方向 " + (d.perp_direction === "short" ? "空" : "多") + : ""); const q = $("hp-perp-quote"); - if (q) { - q.innerHTML = - "可用 " + - fmt(d.available_usdt, 2) + - " USDT · 卖一 " + - fmt(d.ask, 2) + - " · 买一 " + - fmt(d.bid, 2) + - " · 面值 " + - fmt(d.contract_size, 4) + - " · 精度 " + - amtPrec + - " 位"; + if (q) q.innerHTML = quoteHtml; + const rightQ = $("hp-po-perp-quote-right"); + if (rightQ && isOptionPrimary()) { + rightQ.innerHTML = "永续行情 · " + quoteHtml; } const contractsInput = $("hp-contracts"); if (contractsInput) { @@ -760,7 +910,25 @@ } const sz = $("hp-sizing-line"); if (sz) { - if (d.full_margin_sizing) { + if (isOptionPrimary()) { + const sized = + state.selected && + computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); + if (sized) { + sz.innerHTML = + "执行预算 " + + fmt(sized.usable, 2) + + " · 期权 ETH " + + fmt(sized.eth_qty, 2) + + " / " + + sized.sheets + + " 张 · 永续 " + + fmt(sized.contracts, amtPrec) + + " 张"; + } else { + sz.textContent = "填写权利金并选用期权后显示定仓(权利金×0.95,ETH两位小数)"; + } + } else if (d.full_margin_sizing) { const s = d.full_margin_sizing; sz.innerHTML = "全仓建议 " + @@ -777,8 +945,8 @@ } } const entry = $("hp-entry"); - if (entry && d.entry_ref && !entry.value) entry.value = d.entry_ref; - if (contractsInput && d.suggest_contracts != null && !contractsInput.value) { + if (!isOptionPrimary() && entry && d.entry_ref && !entry.value) entry.value = d.entry_ref; + if (!isOptionPrimary() && contractsInput && d.suggest_contracts != null && !contractsInput.value) { contractsInput.value = fmt(d.suggest_contracts, amtPrec); } const label = $("hp-opt-type-label"); @@ -789,6 +957,25 @@ function updatePerpPnlHint() { const el = $("hp-perp-pnl-line"); if (!el) return; + if (isOptionPrimary()) { + const n = numInput("hp-opt-target-pts", NaN); + const m = numInput("hp-perp-target-pts", NaN); + const k = state.selected && Number(state.selected.strike); + if (!(k > 0) || (!(n >= 0) && !(m >= 0))) { + el.innerHTML = '选用期权并填目标点数后显示 K±N 出场参考'; + return; + } + const dir = getDirection(); + const optT = n >= 0 ? (dir === "short" ? k - n : k + n) : null; + const perpT = m >= 0 ? (dir === "short" ? k - m : k + m) : null; + el.innerHTML = + "期权目标指数 " + + (optT != null ? fmt(optT, 2) : "—") + + " · 永续目标指数 " + + (perpT != null ? fmt(perpT, 2) : "—") + + ' (相对K;期权目标需买一且扣费净利>0)'; + return; + } const entry = Number(($("hp-entry") && $("hp-entry").value) || NaN); const tp = Number(($("hp-tp") && $("hp-tp").value) || NaN); const sl = Number(($("hp-sl") && $("hp-sl").value) || NaN); @@ -828,17 +1015,23 @@ function fillExpSelect(sel, chain) { if (!sel) return; const prev = sel.value; + const isPoSel = sel.id === "hp-exp-select"; + const minH = isPoSel && isOptionPrimary() ? numInput("hp-min-hours", 36) : 0; sel.innerHTML = ''; + let firstOk = null; (chain.expiries || []).forEach(function (e) { + const h = hoursFromExpMs(e.exp_time); + if (minH > 0 && h != null && h < minH) return; const opt = document.createElement("option"); opt.value = String(e.exp_time); - const dt = new Date(Number(e.exp_time)); - opt.textContent = dt.toLocaleString(); + const dt = new Date(Number(e.exp_time) < 1e12 ? Number(e.exp_time) * 1000 : Number(e.exp_time)); + opt.textContent = dt.toLocaleString() + (h != null ? " · " + fmt(h, 1) + "h" : ""); sel.appendChild(opt); + if (!firstOk) firstOk = e; }); if (prev) sel.value = prev; - if (!sel.value && chain.expiries && chain.expiries[0]) { - sel.value = String(chain.expiries[0].exp_time); + if (!sel.value && firstOk) { + sel.value = String(firstOk.exp_time); } } @@ -879,10 +1072,14 @@ function pickContract(c) { if (!c) return; const m = (c.moneyness || "").toLowerCase(); - if (m === "otm") { + if (!isOptionPrimary() && m === "otm") { alert("永期保险腿须为实值或平值,不可选虚值"); return; } + if (isOptionPrimary() && !matchesMoneyFilter(c)) { + alert("不符合当前间隔/杠杆/虚实值过滤"); + return; + } state.selected = c; const el = $("hp-sel-inst"); if (el) el.textContent = c.inst_id; @@ -896,6 +1093,12 @@ }); } updatePremiumLine(); + if (isOptionPrimary()) { + const sized = computeOpSizing(c.ask, c.ct_mult || 0.01); + if (sized && $("hp-sheets")) $("hp-sheets").value = String(sized.sheets); + void loadMarket(); + updatePerpPnlHint(); + } } function renderListStrikes() { @@ -1280,29 +1483,62 @@ }; } else { if (!state.selected) throw new Error("请选用期权腿"); - const mSel = (state.selected.moneyness || "").toLowerCase(); - if (mSel === "otm") throw new Error("永期保险腿须为实值或平值,不可选虚值"); - const entry = Number(($("hp-entry") && $("hp-entry").value) || 0); - const tp = Number(($("hp-tp") && $("hp-tp").value) || 0); - const sl = Number(($("hp-sl") && $("hp-sl").value) || 0); - const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || 0); - const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); - if (!entry || !tp || !sl || !contracts) throw new Error("请完整填写开仓/止盈/止损/张数"); - body = { - plan_type: "perp_options", - direction: getDirection(), - entry: entry, - tp: tp, - sl: sl, - contracts: contracts, - contract_size: (state.market && state.market.contract_size) || 0.01, - opt_type: state.selected.opt_type, - strike: state.selected.strike, - sheets: sheets, - ct_mult: state.selected.ct_mult || 0.01, - ask: state.selected.ask, - index_px: indexPx() || entry, - }; + if (isOptionPrimary()) { + const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); + if (!sized) throw new Error("请填写权利金并确认卖一有效"); + const optPts = numInput("hp-opt-target-pts", NaN); + const perpPts = numInput("hp-perp-target-pts", NaN); + if (!(optPts > 0) || !(perpPts > 0)) throw new Error("请填写期权/永续目标位点数(须大于0)"); + const exp = currentExp("hp-exp-select"); + body = { + plan_type: "perp_options", + option_primary: true, + direction: getDirection(), + entry: indexPx() || Number((state.market && state.market.mark) || 0), + contracts: sized.contracts, + sheets: sized.sheets, + contract_size: (state.market && state.market.contract_size) || 0.01, + opt_type: state.selected.opt_type, + strike: state.selected.strike, + ct_mult: state.selected.ct_mult || 0.01, + ask: state.selected.ask, + index_px: indexPx() || 0, + premium_budget: numInput("hp-premium-budget", 0), + option_perp_ratio: numInput("hp-opt-perp-ratio", 2), + 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: numInput("hp-opt-leverage", 100), + leverage: numInput("hp-perp-leverage", 100), + moneyness: opMoneyKind(), + hours_to_expiry: exp ? hoursFromExpMs(exp.exp_time) : null, + }; + } else { + const mSel = (state.selected.moneyness || "").toLowerCase(); + if (mSel === "otm") throw new Error("永期保险腿须为实值或平值,不可选虚值"); + const entry = Number(($("hp-entry") && $("hp-entry").value) || 0); + const tp = Number(($("hp-tp") && $("hp-tp").value) || 0); + const sl = Number(($("hp-sl") && $("hp-sl").value) || 0); + const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || 0); + const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); + if (!entry || !tp || !sl || !contracts) throw new Error("请完整填写开仓/止盈/止损/张数"); + body = { + plan_type: "perp_options", + direction: getDirection(), + entry: entry, + tp: tp, + sl: sl, + contracts: contracts, + contract_size: (state.market && state.market.contract_size) || 0.01, + opt_type: state.selected.opt_type, + strike: state.selected.strike, + sheets: sheets, + ct_mult: state.selected.ct_mult || 0.01, + ask: state.selected.ask, + index_px: indexPx() || entry, + }; + } } const d = await apiJson("/api/hedge-plan/preview", { method: "POST", @@ -1312,7 +1548,18 @@ setGateLine(d.gates); const s = d.summary || {}; if (summary) { - if (d.plan_type === "perp_options") { + if (d.plan_type === "perp_options" && (d.option_primary || s.opt_target_total != null)) { + const sz = d.sizing || {}; + summary.innerHTML = + "期权目标净利 " + + fmtPnlHtml(s.opt_target_total) + + " · 永续目标净利 " + + fmtPnlHtml(s.perp_target_total) + + " · 保费 " + + fmt(s.premium_paid) + + (sz.eth_qty != null ? " · ETH " + fmt(sz.eth_qty, 2) : "") + + (s.perp_direction ? " · 永续" + (s.perp_direction === "short" ? "空" : "多") : ""); + } else if (d.plan_type === "perp_options") { summary.innerHTML = "止盈合计 " + fmtPnlHtml(s.tp_total) + @@ -1439,16 +1686,53 @@ document.querySelectorAll(".hp-money-btn").forEach(function (b) { b.addEventListener("click", function () { const m = b.getAttribute("data-money") || "itm"; - // 永期禁止选虚值筛选 - if (m === "otm") { - alert("永期保险腿仅允许实值或平值"); + if (m === "otm" && !isOptionPrimary()) { + alert("永期保险腿仅允许实值或平值;请先打开「以期权为主」"); return; } - state.moneyFilter = m === "atm" ? "atm" : "itm"; + state.moneyFilter = m === "otm" ? "otm" : m === "atm" ? "atm" : "itm"; + if (isOptionPrimary()) applyOpDefaultsFromMoney(false); syncMoneyUI(); renderListStrikes(); }); }); + document.querySelectorAll(".hp-po-mode").forEach(function (b) { + b.addEventListener("click", function () { + setOptionPrimary(b.getAttribute("data-option-primary") === "1", true); + }); + }); + if ($("hp-opt-leverage")) { + $("hp-opt-leverage").addEventListener("input", function () { + state.opLevTouched = true; + renderListStrikes(); + }); + } + if ($("hp-opt-perp-ratio")) { + $("hp-opt-perp-ratio").addEventListener("input", function () { + state.opRatioTouched = true; + if (state.selected) { + const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); + if (sized && $("hp-sheets")) $("hp-sheets").value = String(sized.sheets); + } + void loadMarket(); + }); + } + ["hp-premium-budget", "hp-strike-interval", "hp-min-hours", "hp-opt-target-pts", "hp-perp-target-pts"].forEach( + function (id) { + const el = $(id); + if (!el) return; + el.addEventListener("input", function () { + if (id === "hp-min-hours" && state.chain) fillExpSelect($("hp-exp-select"), state.chain); + if (id === "hp-strike-interval" || id === "hp-premium-budget") renderListStrikes(); + if (state.selected && (id === "hp-premium-budget" || id === "hp-strike-interval")) { + const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); + if (sized && $("hp-sheets")) $("hp-sheets").value = String(sized.sheets); + void loadMarket(); + } + updatePerpPnlHint(); + }); + } + ); document.querySelectorAll(".hp-oo-money-btn").forEach(function (b) { b.addEventListener("click", function () { const m = b.getAttribute("data-oo-money") || "atm_otm"; @@ -1513,6 +1797,7 @@ const el = $(id); if (el) el.addEventListener("input", updatePerpPnlHint); }); + syncOptionPrimaryUI(); if ($("hp-refresh")) $("hp-refresh").addEventListener("click", function () { void refreshAll(); @@ -2109,32 +2394,69 @@ }; } else { if (!state.selected) throw new Error("请选用期权腿"); - const m = (state.selected.moneyness || "").toLowerCase(); - if (m === "otm") throw new Error("永期保险腿须为实值或平值,不可选虚值"); - const entry = Number(($("hp-entry") && $("hp-entry").value) || 0); - const tp = Number(($("hp-tp") && $("hp-tp").value) || 0); - const sl = Number(($("hp-sl") && $("hp-sl").value) || 0); - const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || 0); - const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); - if (!entry || !tp || !sl || !contracts) throw new Error("请完整填写开仓/止盈/止损/张数"); - body = { - plan_type: "perp_options", - underlying: state.underlying, - direction: getDirection(), - entry: entry, - tp: tp, - sl: sl, - contracts: contracts, - sheets: sheets, - opt_inst_id: state.selected.inst_id, - opt_type: state.selected.opt_type, - strike: state.selected.strike, - ask: state.selected.ask, - index_px: indexPx() || entry, - exchange_symbol: (state.market && state.market.exchange_symbol) || "", - leverage: 10, - margin: state.market && state.market.full_margin_sizing && state.market.full_margin_sizing.margin_capital, - }; + if (isOptionPrimary()) { + const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); + if (!sized) throw new Error("请填写权利金并确认卖一有效"); + const optPts = numInput("hp-opt-target-pts", NaN); + const perpPts = numInput("hp-perp-target-pts", NaN); + if (!(optPts > 0) || !(perpPts > 0)) throw new Error("请填写期权/永续目标位点数(须大于0)"); + const exp = currentExp("hp-exp-select"); + const entry = indexPx() || Number((state.market && state.market.mark) || 0); + body = { + plan_type: "perp_options", + option_primary: true, + underlying: state.underlying, + direction: getDirection(), + entry: entry, + contracts: sized.contracts, + sheets: sized.sheets, + contract_size: (state.market && state.market.contract_size) || 0.01, + ct_mult: state.selected.ct_mult || 0.01, + opt_inst_id: state.selected.inst_id, + opt_type: state.selected.opt_type, + strike: state.selected.strike, + ask: state.selected.ask, + index_px: entry, + exchange_symbol: (state.market && state.market.exchange_symbol) || "", + leverage: numInput("hp-perp-leverage", 100), + option_leverage: numInput("hp-opt-leverage", 100), + premium_budget: numInput("hp-premium-budget", 0), + option_perp_ratio: numInput("hp-opt-perp-ratio", 2), + option_target_points: optPts, + perp_target_points: perpPts, + strike_interval: numInput("hp-strike-interval", 15), + min_option_hours: numInput("hp-min-hours", 36), + moneyness: opMoneyKind(), + hours_to_expiry: exp ? hoursFromExpMs(exp.exp_time) : null, + }; + } else { + const m = (state.selected.moneyness || "").toLowerCase(); + if (m === "otm") throw new Error("永期保险腿须为实值或平值,不可选虚值"); + const entry = Number(($("hp-entry") && $("hp-entry").value) || 0); + const tp = Number(($("hp-tp") && $("hp-tp").value) || 0); + const sl = Number(($("hp-sl") && $("hp-sl").value) || 0); + const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || 0); + const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); + if (!entry || !tp || !sl || !contracts) throw new Error("请完整填写开仓/止盈/止损/张数"); + body = { + plan_type: "perp_options", + underlying: state.underlying, + direction: getDirection(), + entry: entry, + tp: tp, + sl: sl, + contracts: contracts, + sheets: sheets, + opt_inst_id: state.selected.inst_id, + opt_type: state.selected.opt_type, + strike: state.selected.strike, + ask: state.selected.ask, + index_px: indexPx() || entry, + exchange_symbol: (state.market && state.market.exchange_symbol) || "", + leverage: 10, + margin: state.market && state.market.full_margin_sizing && state.market.full_margin_sizing.margin_capital, + }; + } } if (!fromPreviewModal) { if (!window.confirm("确认启动对冲计划并真实下单?\n(将按期权账户/合约账户分别下单)")) return; diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py index 21adc28..f108791 100644 --- a/lib/hedge_plan/hedge_plan_db.py +++ b/lib/hedge_plan/hedge_plan_db.py @@ -74,6 +74,17 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None: _ensure_column(conn, "hedge_plans", "target_price_down", "REAL") # close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状) _ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT") + # 永期「以期权为主」 + _ensure_column(conn, "hedge_plans", "option_primary", "INTEGER") + _ensure_column(conn, "hedge_plans", "option_target_points", "REAL") + _ensure_column(conn, "hedge_plans", "perp_target_points", "REAL") + _ensure_column(conn, "hedge_plans", "option_perp_ratio", "REAL") + _ensure_column(conn, "hedge_plans", "premium_budget", "REAL") + _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", "perp_direction", "TEXT") + _ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL") def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None: diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py index 353df7a..fa6b37c 100644 --- a/lib/hedge_plan/hedge_plan_monitor_lib.py +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -224,7 +224,20 @@ def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[ pt = plan.get("plan_type") legs = get_plan_legs(conn, int(plan["id"])) if pt == "perp_options": - # 先判断期权是否已过期且永续仍在(罕见);主路径仍是永续平仓侦测 + from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary + + if is_option_primary(plan): + # 期权为主:半平重试 → 到期 → 目标位分叉 + r = _tick_po_option_primary_pending(cfg, conn, plan, legs) + if r: + return r + r = _tick_po_option_primary_expiry(cfg, conn, plan, legs) + if r: + return r + r = _tick_po_option_primary_both_expired(cfg, conn, plan, legs) + if r: + return r + return _tick_po_option_primary(cfg, conn, plan, legs) r = _tick_po(cfg, conn, plan, legs) return r if pt == "options_options": @@ -238,13 +251,419 @@ def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[ return None +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]]: + """期权已平、永续待平(opt_target_perp_pending)时只重试平永续.""" + from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view + from lib.hedge_plan.hedge_plan_orders_lib import _close_perp + + pending = str(plan.get("close_reason") or "") + if pending not in ("opt_target_perp_pending", "opt_target_pending"): + return None + perp = next((x for x in legs if x.get("leg_role") == "perp"), None) + opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) + if not perp or str(perp.get("status") or "") != "open": + return None + view = str(plan.get("direction") or "long").lower() + perp_dir = str(plan.get("perp_direction") or perp.get("side") or perp_direction_for_view(view)).lower() + symbol = str(perp.get("symbol") or "") + contracts = float(perp.get("size") or plan.get("perp_size") or 0) + + # 期权仍 open:继续走主路径,不在此强平 + if pending == "opt_target_pending" and opt and str(opt.get("status") or "") == "open": + return None + + # 期权已平或 already flat:只补平永续 + if opt and str(opt.get("status") or "") == "open": + return None + + perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False) + if not perp_close.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期权已平·永续平仓重试失败", + plan_id=plan.get("id"), + detail=str(perp_close.get("msg") or perp_close), + ), + ) + update_plan(conn, int(plan["id"]), close_reason="opt_target_perp_pending") + return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close} + + entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or 0 + mark = entry + ex = cfg.get("exchange") + if ex is not None and symbol: + try: + t = ex.fetch_ticker(symbol) + mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) or entry + except Exception: + pass + cs = float(cfg.get("default_contract_size") or 0.01) + get_cs = cfg.get("get_contract_size") + if callable(get_cs) and symbol: + try: + cs = float(get_cs(symbol) or cs) + except Exception: + pass + coins = contracts * cs + if perp_dir == "short": + perp_pnl = (float(entry or 0) - float(mark or 0)) * coins + else: + perp_pnl = (float(mark or 0) - float(entry or 0)) * coins + opt_pnl = float(opt.get("realized_pnl") or 0) if opt else float(plan.get("realized_pnl_options") or 0) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "opt_target_points", _now(), round(perp_pnl, 4), perp["id"]), + ) + total = opt_pnl + perp_pnl + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason="opt_target_points", + realized_pnl_perp=round(perp_pnl, 4), + realized_pnl_options=round(opt_pnl, 4), + realized_pnl_total=round(total, 4), + stats_bucket="opt_primary", + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": "opt_target_points", "total": total, "recovered": True} + + +def _tick_po_option_primary_both_expired( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] +) -> Optional[dict[str, Any]]: + """两腿仍 open 但期权已到期:结算期权并市价平永续,避免裸奔.""" + from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view + from lib.hedge_plan.hedge_plan_orders_lib import _close_perp + + perp = next((x for x in legs if x.get("leg_role") == "perp"), None) + opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) + if not perp or str(perp.get("status") or "") != "open": + return None + if not opt or str(opt.get("status") or "") != "open": + return None + if not leg_is_expired(opt): + return None + spot = _index_px(cfg, str(plan.get("underlying") or "ETH")) + if spot is None: + return None + est = settle_option_leg_at_spot(opt, float(spot)) + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "expiry", _now(), round(opt_pnl, 4), opt["id"]), + ) + view = str(plan.get("direction") or "long").lower() + perp_dir = str(plan.get("perp_direction") or perp.get("side") or perp_direction_for_view(view)).lower() + symbol = str(perp.get("symbol") or "") + contracts = float(perp.get("size") or plan.get("perp_size") or 0) + perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False) + entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or float(spot) + cs = float(cfg.get("default_contract_size") or 0.01) + get_cs = cfg.get("get_contract_size") + if callable(get_cs) and symbol: + try: + cs = float(get_cs(symbol) or cs) + except Exception: + pass + coins = contracts * cs + if perp_dir == "short": + perp_pnl = (float(entry) - float(spot)) * coins + else: + perp_pnl = (float(spot) - float(entry)) * coins + if not perp_close.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期权到期后永续平仓失败(将重试)", + plan_id=plan.get("id"), + detail=str(perp_close.get("msg") or perp_close), + ), + ) + update_plan( + conn, + int(plan["id"]), + close_reason="opt_target_perp_pending", + realized_pnl_options=round(opt_pnl, 4), + note="期权已到期结算,永续待平", + ) + return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close} + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "option_expired", _now(), round(perp_pnl, 4), perp["id"]), + ) + total = opt_pnl + perp_pnl + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason="option_expired", + realized_pnl_perp=round(perp_pnl, 4), + realized_pnl_options=round(opt_pnl, 4), + realized_pnl_total=round(total, 4), + stats_bucket="opt_primary", + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": "option_expired", "total": total} + + +def _tick_po_option_primary_expiry( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] +) -> Optional[dict[str, Any]]: + """期权为主且永续已平、期权 hold_to_expiry → 到期结算后收口计划.""" + perp = next((x for x in legs if x.get("leg_role") == "perp"), None) + opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) + if not opt or str(opt.get("status") or "") != "hold_to_expiry": + return None + if perp and str(perp.get("status") or "") == "open": + return None + if not leg_is_expired(opt): + return None + spot = _index_px(cfg, str(plan.get("underlying") or "ETH")) + if spot is None: + return None + est = settle_option_leg_at_spot(opt, float(spot)) + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "expiry", _now(), round(opt_pnl, 4), opt["id"]), + ) + perp_pnl = float(perp.get("realized_pnl") or 0) if perp else float(plan.get("realized_pnl_perp") or 0) + total = perp_pnl + opt_pnl + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason="perp_target_points_expiry", + realized_pnl_perp=round(perp_pnl, 4), + realized_pnl_options=round(opt_pnl, 4), + realized_pnl_total=round(total, 4), + stats_bucket="opt_primary", + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": "perp_target_points_expiry", "total": total} + + +def _tick_po_option_primary( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] +) -> Optional[dict[str, Any]]: + """以期权为主:触达目标位立即执行分叉平仓规则.""" + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + estimate_combo_net_pnl, + option_bid_liquidity_ok, + perp_direction_for_view, + target_hit, + ) + from lib.hedge_plan.hedge_plan_orders_lib import _close_perp + + perp = next((x for x in legs if x.get("leg_role") == "perp"), None) + opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) + if not perp or str(perp.get("status") or "") != "open": + return None + if not opt or str(opt.get("status") or "") != "open": + return None + if _within_open_grace(plan): + return None + + view = str(plan.get("direction") or "long").lower() + perp_dir = str(plan.get("perp_direction") or perp.get("side") or perp_direction_for_view(view)).lower() + strike = _sf(opt.get("strike")) + n = _sf(plan.get("option_target_points")) + m = _sf(plan.get("perp_target_points")) + if strike is None or strike <= 0: + return None + idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) + if idx is None: + return None + + hit_opt = bool(n is not None and target_hit(view_side=view, index_px=idx, strike=strike, points=float(n))) + hit_perp = bool(m is not None and target_hit(view_side=view, index_px=idx, strike=strike, points=float(m))) + if not hit_opt and not hit_perp: + return None + + symbol = str(perp.get("symbol") or "") + mark = None + ex = cfg.get("exchange") + if ex is not None and symbol: + try: + t = ex.fetch_ticker(symbol) + mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) + except Exception: + mark = None + mark = mark or idx + entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or mark + cs = float(cfg.get("default_contract_size") or 0.01) + get_cs = cfg.get("get_contract_size") + if callable(get_cs) and symbol: + try: + cs = float(get_cs(symbol) or cs) + except Exception: + pass + + quote_fn = cfg.get("quote_option_contract") + ex_opt = cfg.get("exchange_options") + bid = None + bid_sz = None + if callable(quote_fn) and ex_opt is not None: + try: + q = quote_fn(ex_opt, str(opt.get("inst_id") or "")) + if q.get("ok"): + bid = _sf(q.get("bid")) + bid_sz = _sf(q.get("bid_sz")) + except Exception: + bid = None + + ask_open = _sf(opt.get("avg_open")) or 0.0 + sheets = float(opt.get("size") or 1) + ct = float(opt.get("ct_mult") or 0.01) + contracts = float(perp.get("size") or plan.get("perp_size") or 0) + + # 优先期权目标;买一不足或净利≤0 时若永续目标已触达则改走永续目标 + if hit_opt: + liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=sheets) + net = None + if liq_ok: + net = estimate_combo_net_pnl( + view_side=view, + strike=float(strike), + index_px=float(idx), + ask_open=float(ask_open), + bid=float(bid or 0), + sheets=sheets, + ct_mult=ct, + perp_direction=perp_dir, + perp_entry=float(entry or 0), + perp_mark=float(mark or 0), + contracts=contracts, + contract_size=cs, + ) + can_opt_exit = bool(liq_ok and net is not None and float(net.get("net") or 0) > 0) + if can_opt_exit: + reason = "opt_target_points" + close_r = _sell_option(cfg, inst_id=str(opt.get("inst_id") or ""), sheets=sheets) + if close_r.get("already_flat"): + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=net["opt_net"]) + elif not close_r.get("ok") or not close_r.get("fully_closed", True): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期权目标平仓失败(将重试)", + plan_id=plan.get("id"), + detail=str(close_r.get("msg") or close_r), + ), + ) + update_plan(conn, int(plan["id"]), close_reason="opt_target_pending") + return {"plan_id": plan["id"], "retry": True, "close": close_r} + else: + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=net["opt_net"]) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), round(opt_pnl, 4), opt["id"]), + ) + perp_close = _close_perp( + cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False + ) + if not perp_close.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期权已平但永续平仓失败(将重试)", + plan_id=plan.get("id"), + detail=str(perp_close.get("msg") or perp_close), + ), + ) + update_plan(conn, int(plan["id"]), close_reason="opt_target_perp_pending") + return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close} + perp_pnl = float(net.get("perp_net") or 0) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), round(perp_pnl, 4), perp["id"]), + ) + total = float(opt_pnl) + float(perp_pnl) + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason=reason, + realized_pnl_perp=round(perp_pnl, 4), + realized_pnl_options=round(opt_pnl, 4), + realized_pnl_total=round(total, 4), + stats_bucket="opt_primary", + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": reason, "total": total, "net": net} + if not hit_perp: + return { + "plan_id": plan["id"], + "skip": True, + "msg": (liq_msg if not liq_ok else "净利≤0,继续持有"), + "net": net, + } + + if not hit_perp: + return None + + reason = "perp_target_points" + # 永续目标:平永续,期权持有至到期 + perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False) + if not perp_close.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="永续目标平仓失败(将重试)", + plan_id=plan.get("id"), + detail=str(perp_close.get("msg") or perp_close), + ), + ) + update_plan(conn, int(plan["id"]), close_reason="perp_target_pending") + return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close} + # 估永续已实现 + coins = contracts * cs + if perp_dir == "short": + perp_pnl = (float(entry or 0) - float(mark or 0)) * coins + else: + perp_pnl = (float(mark or 0) - float(entry or 0)) * coins + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), round(perp_pnl, 4), perp["id"]), + ) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?", + ("hold_to_expiry", "hold_expiry_after_perp_target", opt["id"]), + ) + update_plan( + conn, + int(plan["id"]), + # 计划保持 active,等期权到期收口 + close_reason="perp_target_points", + realized_pnl_perp=round(perp_pnl, 4), + note="永续已按目标平仓,期权持有至到期", + ) + notify_hedge( + cfg, + build_hedge_alert_message( + title="永续目标已平·期权持有至到期", + plan_id=plan.get("id"), + detail=f"指数 {idx:.2f} · 永续盈亏约 {perp_pnl:.2f}", + ), + ) + return {"plan_id": plan["id"], "close_reason": reason, "perp_pnl": perp_pnl, "opt_hold": True} + + def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]) -> Optional[dict[str, Any]]: perp = next((x for x in legs if x.get("leg_role") == "perp"), None) opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) if not perp or perp.get("status") != "open": return None symbol = perp.get("symbol") or "" - direction = (plan.get("direction") or "long").lower() + direction = (plan.get("perp_direction") or plan.get("direction") or "long").lower() live = _perp_live_contracts(cfg, symbol, direction) # API 失败 / 未注入 → 本轮跳过,绝不当「已平」 if live is None: diff --git a/lib/hedge_plan/hedge_plan_option_primary_lib.py b/lib/hedge_plan/hedge_plan_option_primary_lib.py new file mode 100644 index 0000000..1f9b577 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_option_primary_lib.py @@ -0,0 +1,425 @@ +"""永期「以期权为主」:定仓、方向映射、目标位与净利口径(纯函数为主).""" +from __future__ import annotations + +import math +import os +from typing import Any, Optional + +PREMIUM_EXEC_FACTOR = 0.95 +DEFAULT_MIN_HOURS = 36.0 +DEFAULT_STRIKE_INTERVAL = 15.0 +DEFAULT_PERP_LEVERAGE = 100 +DEFAULT_OPT_LEVERAGE_ITM_ATM = 100.0 +DEFAULT_OPT_LEVERAGE_OTM = 200.0 +DEFAULT_RATIO_ITM_ATM = 2.0 +DEFAULT_RATIO_OTM = 4.0 +OTM_LEV_FLOOR = 180.0 + + +def _sf(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def is_option_primary(body_or_plan: dict[str, Any] | None) -> bool: + if not body_or_plan: + return False + v = body_or_plan.get("option_primary") + if v in (True, 1, "1", "true", "yes", "on"): + return True + try: + return int(v or 0) == 1 + except (TypeError, ValueError): + return False + + +def fee_rate() -> float: + try: + return max(0.0, float(os.getenv("HEDGE_PLAN_FEE_RATE") or os.getenv("OKX_TAKER_FEE") or "0.0005")) + except (TypeError, ValueError): + return 0.0005 + + +def floor2(v: float) -> float: + """ETH 数量向下取两位小数.""" + if v <= 0: + return 0.0 + return math.floor(float(v) * 100.0 + 1e-12) / 100.0 + + +def opt_type_for_view(direction: str) -> str: + """看法做多→Call,做空→Put.""" + return "P" if str(direction or "").strip().lower() == "short" else "C" + + +def perp_direction_for_view(direction: str) -> str: + """看法做多→永续空,做空→永续多.""" + return "long" if str(direction or "").strip().lower() == "short" else "short" + + +def default_opt_leverage(moneyness: str) -> float: + m = (moneyness or "").strip().lower() + return DEFAULT_OPT_LEVERAGE_OTM if m == "otm" else DEFAULT_OPT_LEVERAGE_ITM_ATM + + +def default_ratio(moneyness: str) -> float: + m = (moneyness or "").strip().lower() + return DEFAULT_RATIO_OTM if m == "otm" else DEFAULT_RATIO_ITM_ATM + + +def effective_min_opt_leverage(moneyness: str, configured: Any) -> float: + cfg = _sf(configured) + base = cfg if cfg is not None and cfg > 0 else default_opt_leverage(moneyness) + if (moneyness or "").strip().lower() == "otm": + return max(base, OTM_LEV_FLOOR) + return base + + +def hours_to_expiry_from_ms(exp_ms: Any, *, now_ms: Optional[float] = None) -> Optional[float]: + exp = _sf(exp_ms) + if exp is None or exp <= 0: + return None + # OKX exp 多为毫秒 + if exp < 1e12: + exp *= 1000.0 + now = now_ms if now_ms is not None else __import__("time").time() * 1000.0 + return (exp - now) / 3600000.0 + + +def target_hit(*, view_side: str, index_px: float, strike: float, points: float) -> bool: + """相对 K 的点数目标:做多 index≥K+N;做空 index≤K−N.点数须 >0.""" + n = float(points or 0) + k = float(strike) + s = float(index_px) + if n <= 0 or k <= 0 or s <= 0: + return False + side = str(view_side or "").strip().lower() + if side == "short": + return s <= (k - n) + return s >= (k + n) + + +def option_bid_liquidity_ok(bid: Any, bid_sz: Any, *, need_sheets: float = 0) -> tuple[bool, str]: + b = _sf(bid) + if b is None or b <= 0: + return False, "暂无买一报价,无法平期权" + sz = _sf(bid_sz) + if sz is not None and sz <= 0: + return False, "买一深度为 0,无法平期权" + need = float(need_sheets or 0) + if need > 0 and sz is not None and sz + 1e-12 < need: + return False, f"买一深度不足(需 {need:g} 张,买一 {sz:g})" + return True, "" + + +def size_from_premium( + *, + premium_budget: float, + ask: float, + ct_mult: float, + ratio: float, + contract_size: float, + exec_factor: float = PREMIUM_EXEC_FACTOR, +) -> dict[str, Any]: + """权利金×0.95 → ETH 两位小数 → 期权张 → 永续跟比例.""" + budget = float(premium_budget or 0) + a = float(ask or 0) + ct = float(ct_mult or 0.01) + r = float(ratio or 0) + cs = float(contract_size or 0.01) + usable = budget * float(exec_factor or PREMIUM_EXEC_FACTOR) + if budget <= 0 or a <= 0 or ct <= 0 or r <= 0 or cs <= 0: + return { + "ok": False, + "msg": "定仓参数无效", + "usable_premium": round(usable, 4), + "eth_qty": 0.0, + "sheets": 0.0, + "perp_eth": 0.0, + "contracts": 0.0, + } + # ask 为每 1 币权利金;ETH 数量 = usable / ask + eth_qty = floor2(usable / a) + if eth_qty <= 0: + return { + "ok": False, + "msg": "权利金不足以买入 0.01 ETH 名义期权", + "usable_premium": round(usable, 4), + "eth_qty": 0.0, + "sheets": 0.0, + "perp_eth": 0.0, + "contracts": 0.0, + } + sheets = eth_qty / ct + # 张数向下取整到整数张(OKX 期权常见整张) + sheets_i = float(math.floor(sheets + 1e-12)) + if sheets_i <= 0: + return { + "ok": False, + "msg": "换算期权张数不足 1 张", + "usable_premium": round(usable, 4), + "eth_qty": eth_qty, + "sheets": 0.0, + "perp_eth": 0.0, + "contracts": 0.0, + } + # 用整张回写 ETH,保持与下单一致 + eth_qty = round(sheets_i * ct, 2) + perp_eth = eth_qty / r + contracts = perp_eth / cs + premium_est = a * sheets_i * ct + return { + "ok": True, + "msg": "", + "usable_premium": round(usable, 4), + "eth_qty": eth_qty, + "sheets": sheets_i, + "perp_eth": round(perp_eth, 6), + "contracts": contracts, + "premium_est": round(premium_est, 4), + "ratio": r, + "exec_factor": float(exec_factor or PREMIUM_EXEC_FACTOR), + } + + +def estimate_combo_net_pnl( + *, + view_side: str, + strike: float, + index_px: float, + ask_open: float, + bid: float, + sheets: float, + ct_mult: float, + perp_direction: str, + perp_entry: float, + perp_mark: float, + contracts: float, + contract_size: float, + fee: Optional[float] = None, +) -> dict[str, Any]: + """组合净利(扣费);平仓/卖出手续费按买入费率估算.""" + fr = fee if fee is not None else fee_rate() + ct = float(ct_mult or 0.01) + sh = float(sheets or 0) + a = float(ask_open or 0) + b = float(bid or 0) + premium = a * sh * ct + opt_proceeds = b * sh * ct + opt_open_fee = premium * fr + opt_close_fee = opt_proceeds * fr # 卖出费用按买入费率 + opt_net = opt_proceeds - premium - opt_open_fee - opt_close_fee + + coins = float(contracts or 0) * float(contract_size or 0.01) + entry = float(perp_entry or 0) + mark = float(perp_mark or 0) + pd = str(perp_direction or "").strip().lower() + if pd == "short": + perp_gross = (entry - mark) * coins + else: + perp_gross = (mark - entry) * coins + perp_notional_open = abs(entry * coins) + perp_notional_close = abs(mark * coins) + perp_open_fee = perp_notional_open * fr + perp_close_fee = perp_notional_close * fr + perp_net = perp_gross - perp_open_fee - perp_close_fee + total = opt_net + perp_net + return { + "opt_net": round(opt_net, 4), + "perp_net": round(perp_net, 4), + "net": round(total, 4), + "fee_rate": fr, + "premium": round(premium, 4), + "opt_proceeds": round(opt_proceeds, 4), + } + + +def validate_option_primary_moneyness( + *, + opt_type: str, + strike: Any, + index_px: Any, + ask: Any = None, + moneyness: str = "atm", + strike_interval: Any = DEFAULT_STRIKE_INTERVAL, + min_hours: Any = DEFAULT_MIN_HOURS, + hours_to_expiry: Any = None, + min_opt_leverage: Any = None, +) -> Optional[str]: + from lib.hedge_plan.hedge_plan_moneyness_lib import ( + classify_moneyness, + is_atm_or_otm, + is_itm_or_atm, + normalize_opt_type, + ) + + o = normalize_opt_type(opt_type) + k = _sf(strike) + s = _sf(index_px) + if o not in ("C", "P"): + return "期权类型无效" + if k is None or s is None or s <= 0: + return "行权价或指数无效" + m_want = (moneyness or "atm").strip().lower() + m_got = classify_moneyness(opt_type=o, strike=k, index_px=s) + if m_want == "itm": + if not is_itm_or_atm(opt_type=o, strike=k, index_px=s): + return "所选须为实值或平值" + elif m_want == "atm": + # 平值:距指数在间隔内即可(不强制 classify==atm) + pass + elif m_want == "otm": + if m_got == "itm": + return "虚值模式不可选实值" + if not is_atm_or_otm(opt_type=o, strike=k, index_px=s): + return "虚值模式须选虚值或平值档" + else: + return "期权类型(实/平/虚)无效" + + interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL) + if interval > 0 and abs(k - s) > interval + 1e-9: + return f"行权价偏离指数 {abs(k - s):.1f} > 间隔 {interval:.0f}" + + min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS) + h = _sf(hours_to_expiry) + if min_h > 0 and h is not None and h < min_h: + return f"剩余到期约 {h:.1f}h,低于最短 {min_h:.0f}h" + + a = _sf(ask) + 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 a is not None and a > 0: + lev = s / a + if lev < min_lev: + return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}" + return None + + +def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]: + need = ( + "direction", + "contracts", + "opt_inst_id", + "sheets", + "exchange_symbol", + "premium_budget", + "option_target_points", + "perp_target_points", + "option_perp_ratio", + ) + for k in need: + if body.get(k) in (None, ""): + return f"缺少字段: {k}" + try: + if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0: + return "张数必须大于 0" + 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" + except (TypeError, ValueError): + return "数值字段无效" + direction = str(body.get("direction") or "").strip().lower() + if direction not in ("long", "short"): + return "方向须为 long 或 short" + opt_type = str(body.get("opt_type") or "").strip().upper() + if not opt_type: + inst = str(body.get("opt_inst_id") or "") + if inst.upper().endswith("-P"): + opt_type = "P" + elif inst.upper().endswith("-C"): + opt_type = "C" + 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() + from lib.hedge_plan.hedge_plan_moneyness_lib import parse_strike_from_inst + + strike = body.get("strike") + if strike in (None, ""): + strike = parse_strike_from_inst(str(body.get("opt_inst_id") or "")) + index_px = body.get("index_px") or body.get("entry") + return validate_option_primary_moneyness( + opt_type=opt_type, + strike=strike, + index_px=index_px, + ask=body.get("ask"), + moneyness=moneyness, + strike_interval=body.get("strike_interval", DEFAULT_STRIKE_INTERVAL), + min_hours=body.get("min_option_hours", DEFAULT_MIN_HOURS), + hours_to_expiry=body.get("hours_to_expiry"), + min_opt_leverage=body.get("option_leverage") or body.get("min_opt_leverage"), + ) + + +def build_option_primary_preview(body: dict[str, Any]) -> dict[str, Any]: + """情景:期权目标 / 永续目标粗估净利.""" + view = str(body.get("direction") or "long").lower() + strike = float(body["strike"]) + n = float(body.get("option_target_points") or 0) + m = float(body.get("perp_target_points") or 0) + ask = float(body.get("ask") or 0) + sheets = float(body.get("sheets") or 0) + ct = float(body.get("ct_mult") or 0.01) + contracts = float(body.get("contracts") or 0) + cs = float(body.get("contract_size") or 0.01) + entry = float(body.get("entry") or body.get("index_px") or 0) + perp_dir = perp_direction_for_view(view) + # 粗估到点时期权卖价:按内在价值近似(下限 0) + def intrinsic(spot: float) -> float: + o = opt_type_for_view(view) + if o == "C": + return max(0.0, spot - strike) + return max(0.0, strike - spot) + + scenarios = [] + for label, pts, reason in ( + ("期权目标", n, "opt_target_points"), + ("永续目标", m, "perp_target_points"), + ): + spot = strike + pts if view != "short" else strike - pts + bid_est = max(ask * 0.5, intrinsic(spot) * 0.85) # 保守估价 + net = estimate_combo_net_pnl( + view_side=view, + strike=strike, + index_px=spot, + ask_open=ask, + bid=bid_est, + sheets=sheets, + ct_mult=ct, + perp_direction=perp_dir, + perp_entry=entry, + perp_mark=spot, + contracts=contracts, + contract_size=cs, + ) + scenarios.append( + { + "label": label, + "reason": reason, + "index": spot, + "perp_pnl": net["perp_net"], + "options_pnl": net["opt_net"], + "total": net["net"], + "note": "扣费净利估价;平仓费按买入费率", + } + ) + premium = ask * sheets * ct + return { + "plan_type": "perp_options", + "option_primary": True, + "summary": { + "premium_paid": round(premium, 4), + "usable_premium": round(float(body.get("premium_budget") or 0) * PREMIUM_EXEC_FACTOR, 4), + "opt_target_total": scenarios[0]["total"] if scenarios else None, + "perp_target_total": scenarios[1]["total"] if len(scenarios) > 1 else None, + "perp_direction": perp_dir, + "opt_type": opt_type_for_view(view), + }, + "scenarios": scenarios, + } diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index 4ec4750..198d5f9 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -37,7 +37,15 @@ def partial_auto_close_enabled() -> bool: def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]: """永期下单路径清单(不交易).""" - mode = open_order_mode() + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + is_option_primary, + perp_direction_for_view, + ) + + opt_primary = is_option_primary(body) + mode = "options_first" if opt_primary else open_order_mode() + view = str(body.get("direction") or "long") + perp_dir = perp_direction_for_view(view) if opt_primary else view opt = { "step": "options_buy_limit", "account": "options", @@ -50,11 +58,13 @@ def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]: "step": "perp_market_open", "account": "swap", "symbol": body.get("exchange_symbol"), - "direction": body.get("direction") or "long", + "direction": perp_dir, "contracts": float(body.get("contracts") or 0), - "tp": body.get("tp"), - "sl": body.get("sl"), - "attach_tpsl": True, + "tp": None if opt_primary else body.get("tp"), + "sl": None if opt_primary else body.get("sl"), + "attach_tpsl": False if opt_primary else True, + "option_primary": opt_primary, + "view_side": view, } return [opt, perp] if mode == "options_first" else [perp, opt] @@ -249,9 +259,10 @@ def _open_perp( direction: str, contracts: float, leverage: int, - tp: float, - sl: float, + tp: Optional[float], + sl: Optional[float], dry_run: bool, + attach_tpsl: bool = True, ) -> dict[str, Any]: if not symbol or contracts <= 0: return {"ok": False, "msg": "永续符号或张数无效"} @@ -265,6 +276,9 @@ def _open_perp( pass if amount <= 0: return {"ok": False, "msg": "张数经精度舍入后为 0"} + use_tpsl = bool(attach_tpsl) and tp is not None and sl is not None + tp_v = float(tp) if use_tpsl else None + sl_v = float(sl) if use_tpsl else None if dry_run: return { "ok": True, @@ -273,8 +287,9 @@ def _open_perp( "direction": direction, "contracts": amount, "leverage": leverage, - "tp": tp, - "sl": sl, + "tp": tp_v, + "sl": sl_v, + "attach_tpsl": use_tpsl, } ensure = cfg.get("ensure_okx_live_ready") if callable(ensure): @@ -285,7 +300,14 @@ def _open_perp( if not callable(place): return {"ok": False, "msg": "永续下单函数未注入"} try: - order = place(symbol, direction, amount, leverage, stop_loss=sl, take_profit=tp) + order = place( + symbol, + direction, + amount, + leverage, + stop_loss=sl_v, + take_profit=tp_v, + ) except Exception as e: return {"ok": False, "msg": f"永续开仓失败: {e}"} return { @@ -294,13 +316,60 @@ def _open_perp( "direction": direction, "contracts": amount, "leverage": leverage, - "tp": tp, - "sl": sl, + "tp": tp_v, + "sl": sl_v, + "attach_tpsl": use_tpsl, "order": order, "exchange_ord_id": str((order or {}).get("id") or (order or {}).get("info", {}).get("ordId") or ""), } +def _close_perp( + cfg: dict[str, Any], + *, + symbol: str, + direction: str, + contracts: float, + dry_run: bool = False, +) -> dict[str, Any]: + """市价平永续(reduce-only);优先用注入的 close_exchange_order.""" + if not symbol: + return {"ok": False, "msg": "永续符号无效"} + if dry_run: + return { + "ok": True, + "dry_run": True, + "symbol": symbol, + "direction": direction, + "contracts": float(contracts or 0), + } + close_fn = cfg.get("close_exchange_order") + if callable(close_fn): + try: + order = close_fn( + { + "exchange_symbol": symbol, + "direction": direction, + "order_amount": float(contracts or 0), + "symbol": symbol, + } + ) + return {"ok": True, "symbol": symbol, "direction": direction, "order": order} + except Exception as e: + return {"ok": False, "msg": f"永续平仓失败: {e}"} + # 回退:对向市价 reduce-only(若注入了 place + 支持) + place = cfg.get("place_exchange_order") + if not callable(place): + return {"ok": False, "msg": "永续平仓函数未注入"} + try: + # 无 TP/SL 的对向单;依赖交易所 reduceOnly 由 place 实现不保证,优先 close_exchange_order + side_dir = "short" if str(direction).lower() == "long" else "long" + order = place(symbol, side_dir, float(contracts or 0), int(cfg.get("alt_leverage") or 5), None, None) + return {"ok": True, "symbol": symbol, "direction": direction, "order": order, "note": "fallback_place"} + except Exception as e: + return {"ok": False, "msg": f"永续平仓失败: {e}"} + + def _sell_option( cfg: dict[str, Any], *, @@ -604,8 +673,9 @@ def refresh_oo_sizing_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: - """永期启动前再拉保险腿卖一(张数沿用页面值,不按预算重算).""" + """永期启动前再拉卖一;保险模式张数沿用页面;期权为主时按权利金×0.95重算定仓.""" from lib.exchange.okx_options_lib import option_buy_liquidity_ok + from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary, size_from_premium inst = str(body.get("opt_inst_id") or "").strip() if not inst: @@ -628,6 +698,51 @@ def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, An body["ask_sz"] = q.get("ask_sz") if q.get("ct_mult") is not None: body["ct_mult"] = float(q.get("ct_mult") or 0.01) + if is_option_primary(body): + cs = float(body.get("contract_size") or 0.01) + get_cs = cfg.get("get_contract_size") + sym = str(body.get("exchange_symbol") or "") + if callable(get_cs) and sym: + try: + cs = float(get_cs(sym) or cs) + except Exception: + pass + sized = size_from_premium( + premium_budget=float(body.get("premium_budget") or 0), + ask=float(body["ask"]), + ct_mult=float(body.get("ct_mult") or 0.01), + ratio=float(body.get("option_perp_ratio") or 2), + contract_size=cs, + ) + if not sized.get("ok"): + return {"ok": False, "msg": sized.get("msg") or "定仓失败", "quote": q, "sizing": sized} + body["sheets"] = sized["sheets"] + body["contracts"] = sized["contracts"] + body["eth_qty"] = sized["eth_qty"] + body["contract_size"] = cs + # 深度不足则缩量 + ask_sz = float(q.get("ask_sz") or 0) + if ask_sz > 0 and float(body["sheets"]) > ask_sz: + body["sheets"] = float(int(ask_sz)) + if body["sheets"] <= 0: + return {"ok": False, "msg": "卖一深度不足 1 张", "quote": q, "sizing": sized} + eth = round(float(body["sheets"]) * float(body.get("ct_mult") or 0.01), 2) + body["eth_qty"] = eth + body["contracts"] = (eth / float(body.get("option_perp_ratio") or 2)) / cs + return { + "ok": True, + "ask": float(q["ask"]), + "ask_sz": q.get("ask_sz"), + "sheets": body.get("sheets"), + "contracts": body.get("contracts"), + "eth_qty": body.get("eth_qty"), + "sizing": sized, + "quote": q, + "msg": ( + f"期权为主定仓: 权利金×0.95→{body.get('eth_qty')}ETH / " + f"{body.get('sheets')}张期权 / {float(body.get('contracts') or 0):.4f}张永续 @{q['ask']}" + ), + } return { "ok": True, "ask": float(q["ask"]), @@ -685,15 +800,32 @@ def execute_perp_options_start( ) return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results} else: + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + is_option_primary, + perp_direction_for_view, + ) + + opt_primary = is_option_primary(body) + view = str(body.get("direction") or "long") + perp_dir = str(step.get("direction") or ( + perp_direction_for_view(view) if opt_primary else view + )) + attach = bool(step.get("attach_tpsl", not opt_primary)) + tp_v = None if not attach else body.get("tp") + sl_v = None if not attach else body.get("sl") + if attach: + tp_v = float(body["tp"]) + sl_v = float(body["sl"]) perp_res = _open_perp( cfg, symbol=str(body.get("exchange_symbol") or ""), - direction=str(body.get("direction") or "long"), + direction=perp_dir, contracts=float(body.get("contracts") or 0), - leverage=int(body.get("leverage") or 10), - tp=float(body["tp"]), - sl=float(body["sl"]), + leverage=int(body.get("leverage") or (100 if opt_primary else 10)), + tp=tp_v, + sl=sl_v, dry_run=dry_run, + attach_tpsl=attach, ) results.append({"step": step["step"], **perp_res}) if not perp_res.get("ok"): @@ -879,15 +1011,25 @@ def execute_complete_missing_leg( role = str(missing.get("leg_role") or "") results: list[dict[str, Any]] = [] if role == "perp": + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + is_option_primary, + perp_direction_for_view, + ) + + opt_primary = is_option_primary(start_body) + view = str(start_body.get("direction") or "long") + perp_dir = perp_direction_for_view(view) if opt_primary else view + attach = not opt_primary res = _open_perp( cfg, symbol=str(start_body.get("exchange_symbol") or missing.get("symbol") or ""), - direction=str(start_body.get("direction") or "long"), + direction=perp_dir, contracts=float(start_body.get("contracts") or missing.get("size") or 0), - leverage=int(start_body.get("leverage") or 10), - tp=float(start_body["tp"]), - sl=float(start_body["sl"]), + leverage=int(start_body.get("leverage") or (100 if opt_primary else 10)), + tp=None if not attach else float(start_body["tp"]), + sl=None if not attach else float(start_body["sl"]), dry_run=dry_run, + attach_tpsl=attach, ) results.append({"step": "perp_market_open", "complete": True, **res}) if not res.get("ok"): @@ -930,6 +1072,13 @@ def execute_complete_missing_leg( def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: pt = (plan_type or "").strip().lower() if pt == "perp_options": + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + is_option_primary, + validate_option_primary_start, + ) + + if is_option_primary(body): + return validate_option_primary_start(body) need = ("direction", "entry", "tp", "sl", "contracts", "opt_inst_id", "sheets", "exchange_symbol") for k in need: if body.get(k) in (None, ""): diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index 027e1a4..f7a6358 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -79,6 +79,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "ensure_markets_loaded": getattr(app_module, "ensure_markets_loaded", None), "ensure_okx_live_ready": getattr(app_module, "ensure_okx_live_ready", None), "place_exchange_order": getattr(app_module, "place_exchange_order", None), + "close_exchange_order": getattr(app_module, "close_exchange_order", None), "get_live_position_contracts": getattr(app_module, "get_live_position_contracts", None), "amount_to_precision": _amount_to_precision, "build_option_chain": build_option_chain, @@ -298,34 +299,57 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any opt_ok = True perp_ok = True premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0 - plan_id = insert_plan( - conn, - { - "plan_type": "perp_options", - "status": "partial" if is_partial else "active", - "underlying": str(body.get("underlying") or "ETH").upper(), - "direction": str(body.get("direction") or "long"), - "entry_mark": float(body.get("entry") or 0), - "tp": float(body.get("tp") or 0), - "sl": float(body.get("sl") or 0), - "sizing_mode_at_open": load_position_sizing_mode(), - "perp_size": float((perp or {}).get("contracts") or body.get("contracts") or 0), - "margin": body.get("margin"), - "leverage": float(body.get("leverage") or 10), - "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 None, - }, + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + is_option_primary, + perp_direction_for_view, ) + + opt_primary = is_option_primary(body) + view = str(body.get("direction") or "long") + perp_dir = ( + str((perp or {}).get("direction") or "") + or (perp_direction_for_view(view) if opt_primary else view) + ) + plan_row = { + "plan_type": "perp_options", + "status": "partial" if is_partial else "active", + "underlying": str(body.get("underlying") or "ETH").upper(), + "direction": view, + "entry_mark": float(body.get("entry") or 0), + "tp": float(body.get("tp") or 0) if not opt_primary else 0, + "sl": float(body.get("sl") or 0) if not opt_primary else 0, + "sizing_mode_at_open": load_position_sizing_mode(), + "perp_size": float((perp or {}).get("contracts") or body.get("contracts") or 0), + "margin": body.get("margin"), + "leverage": float(body.get("leverage") or (100 if opt_primary else 10)), + "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 None, + "option_primary": 1 if opt_primary else 0, + "perp_direction": perp_dir, + } + if opt_primary: + plan_row.update( + { + "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": str(body.get("moneyness") or body.get("option_moneyness") or ""), + } + ) + plan_id = insert_plan(conn, plan_row) insert_leg( conn, { "plan_id": plan_id, "leg_role": "perp", "symbol": str(body.get("exchange_symbol") or ""), - "side": str(body.get("direction") or "long"), + "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", @@ -343,8 +367,9 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any "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 0) if opt_ok else None, + "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, @@ -475,22 +500,43 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: direction = (request.args.get("direction") or "long").strip().lower() if direction not in ("long", "short"): direction = "long" + option_primary = (request.args.get("option_primary") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) data, err = _fetch_perp_market(cfg, base) if err: return jsonify({"ok": False, "msg": err}), 400 sizing_mode = load_position_sizing_mode() gates = _gates_dict(cfg, "perp_options") + if option_primary: + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + opt_type_for_view, + perp_direction_for_view, + ) + + suggested = opt_type_for_view(direction) + perp_dir = perp_direction_for_view(direction) + acct_note = "以期权为主:看法腿买期权,永续反向对冲" + else: + suggested = "P" if direction == "long" else "C" + perp_dir = direction + acct_note = "永续腿使用合约(交易)账户可用 USDT" out = { "ok": True, "base": base, "direction": direction, - "suggested_opt_type": "P" if direction == "long" else "C", + "option_primary": option_primary, + "suggested_opt_type": suggested, + "perp_direction": perp_dir, **data, "gates": gates, "sizing_mode": sizing_mode, "account_kind": "perp", "account_label": cfg.get("perp_account_label") or "合约账户", - "account_note": "永续腿使用合约(交易)账户可用 USDT", + "account_note": acct_note, } return jsonify(out) @@ -590,13 +636,18 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: err = validate_start_body(plan_type, body) if err: return jsonify({"ok": False, "msg": err, "gates": gates}), 400 - # 补齐永续杠杆 + # 补齐永续杠杆(以期权为主默认 100;保险模式 BTC/ETH 用 btc_leverage) if plan_type == "perp_options" and not body.get("leverage"): - base = str(body.get("underlying") or "ETH").upper() - body["leverage"] = cfg.get("btc_leverage") if base == "BTC" else (cfg.get("btc_leverage") or 10) - # ETH 也用 BTC 档 10x 按方案;ALT 为 alt_leverage 仅非 BTC/ETH - if base in ("BTC", "ETH"): - body["leverage"] = int(cfg.get("btc_leverage") or 10) + from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary + + if is_option_primary(body): + body["leverage"] = 100 + else: + base = str(body.get("underlying") or "ETH").upper() + if base in ("BTC", "ETH"): + body["leverage"] = int(cfg.get("btc_leverage") or 10) + else: + body["leverage"] = int(cfg.get("alt_leverage") or 5) if plan_type == "options_options": out = execute_options_options_start( cfg, @@ -892,6 +943,35 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: def _preview_po(body: dict[str, Any]) -> dict[str, Any]: from lib.hedge_plan.hedge_plan_moneyness_lib import validate_po_option_moneyness + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + build_option_primary_preview, + is_option_primary, + size_from_premium, + validate_option_primary_start, + ) + + if is_option_primary(body): + err = validate_option_primary_start(body) + if err: + raise ValueError(err) + sized = size_from_premium( + premium_budget=float(body.get("premium_budget") or 0), + ask=float(body.get("ask") or 0), + ct_mult=float(body.get("ct_mult") or 0.01), + ratio=float(body.get("option_perp_ratio") or 2), + contract_size=float(body.get("contract_size") or 0.01), + ) + if not sized.get("ok"): + raise ValueError(sized.get("msg") or "定仓失败") + body = dict(body) + body["sheets"] = sized["sheets"] + body["contracts"] = sized["contracts"] + body["eth_qty"] = sized["eth_qty"] + if not body.get("entry"): + body["entry"] = body.get("index_px") or 0 + out = build_option_primary_preview(body) + out["sizing"] = sized + return out direction = str(body.get("direction") or "long").lower() entry = float(body["entry"]) diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index 29f2afb..ef0c780 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -43,28 +43,32 @@
-

永续 · ETH 合约账户

+

永续 · ETH 合约账户

规则说明
-

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

-

下单:先「计算」再「启动」。启动瞬间会再拉卖一并以 IOC 等完全成交;半腿失败可补开或「结束计划」(不平仓)。永期开仓需全仓计仓 + 对冲实盘门禁。

-

板块:左填永续开仓/止盈止损与张数;右选保险腿(做多配 Put、做空配 Call)。保险腿仅允许实值或平值(禁虚值)。止盈后保险腿默认可持有;止损会联动平期权。

+

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

+

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

+

以期权为主(开关开):做多买 Call+永续空、做空买 Put+永续多;左填权利金/杠杆/比例/目标点数;开仓先期权后市价永续;期权目标验买一且净利>0后双平;永续目标只平永续、期权持有至到期。

+
+ + +
- - + +
标记 —

加载中…

-
+
+
@@ -89,11 +127,13 @@

期权 · Put 期权账户

+
- + + 指数 —
@@ -328,4 +368,4 @@
- + diff --git a/tests/test_hedge_plan_option_primary.py b/tests/test_hedge_plan_option_primary.py new file mode 100644 index 0000000..bf3a2be --- /dev/null +++ b/tests/test_hedge_plan_option_primary.py @@ -0,0 +1,144 @@ +"""永期「以期权为主」定仓/方向/目标位/校验.""" +import unittest + +from lib.hedge_plan.hedge_plan_option_primary_lib import ( + PREMIUM_EXEC_FACTOR, + build_option_primary_preview, + estimate_combo_net_pnl, + floor2, + opt_type_for_view, + option_bid_liquidity_ok, + perp_direction_for_view, + size_from_premium, + target_hit, + validate_option_primary_start, +) +from lib.hedge_plan.hedge_plan_orders_lib import build_po_path_plan, validate_start_body + + +class TestOptionPrimary(unittest.TestCase): + def test_direction_mapping(self): + self.assertEqual(opt_type_for_view("long"), "C") + self.assertEqual(opt_type_for_view("short"), "P") + self.assertEqual(perp_direction_for_view("long"), "short") + self.assertEqual(perp_direction_for_view("short"), "long") + + def test_size_from_premium_095_and_eth_2dp(self): + # ask=10 → 1 ETH 成本 10U; 预算 100 → usable 95 → eth=9.5 → sheets=950 (ct=0.01) + sized = size_from_premium( + premium_budget=100, + ask=10, + ct_mult=0.01, + ratio=2, + contract_size=0.01, + ) + self.assertTrue(sized["ok"]) + self.assertAlmostEqual(sized["usable_premium"], 95.0) + self.assertEqual(sized["eth_qty"], 9.5) + self.assertEqual(sized["sheets"], 950.0) + # perp_eth = 9.5/2=4.75; contracts=4.75/0.01=475 + self.assertAlmostEqual(sized["contracts"], 475.0) + self.assertEqual(PREMIUM_EXEC_FACTOR, 0.95) + + def test_floor2(self): + self.assertEqual(floor2(1.239), 1.23) + self.assertEqual(floor2(0.009), 0.0) + + def test_target_hit(self): + self.assertTrue(target_hit(view_side="long", index_px=1950, strike=1900, points=50)) + self.assertFalse(target_hit(view_side="long", index_px=1949, strike=1900, points=50)) + self.assertTrue(target_hit(view_side="short", index_px=1850, strike=1900, points=50)) + self.assertFalse(target_hit(view_side="short", index_px=1851, strike=1900, points=50)) + self.assertFalse(target_hit(view_side="long", index_px=1900, strike=1900, points=0)) + + def test_bid_liquidity(self): + ok, _ = option_bid_liquidity_ok(1.2, 10, need_sheets=5) + self.assertTrue(ok) + ok2, msg = option_bid_liquidity_ok(None, 10, need_sheets=1) + self.assertFalse(ok2) + self.assertIn("买一", msg) + + def test_net_pnl_uses_buy_fee_for_sell(self): + net = estimate_combo_net_pnl( + view_side="long", + strike=1900, + index_px=1950, + ask_open=20, + bid=30, + sheets=2, + ct_mult=0.01, + perp_direction="short", + perp_entry=1900, + perp_mark=1950, + contracts=10, + contract_size=0.01, + fee=0.001, + ) + # opt: proceeds=30*2*0.01=0.6; premium=0.4; fees=0.0004+0.0006; opt_net=0.6-0.4-0.001=0.199 + self.assertIn("net", net) + self.assertEqual(net["fee_rate"], 0.001) + + def test_path_option_primary_no_tpsl_options_first(self): + path = build_po_path_plan( + { + "option_primary": True, + "direction": "long", + "opt_inst_id": "ETH-USD-260831-1900-C", + "sheets": 2, + "exchange_symbol": "ETH/USDT:USDT", + "contracts": 1, + } + ) + self.assertEqual(path[0]["step"], "options_buy_limit") + self.assertEqual(path[1]["direction"], "short") + self.assertFalse(path[1]["attach_tpsl"]) + + def test_validate_option_primary_start(self): + body = { + "option_primary": True, + "direction": "long", + "contracts": 1, + "opt_inst_id": "ETH-USD-260831-1900-C", + "opt_type": "C", + "sheets": 2, + "exchange_symbol": "ETH/USDT:USDT", + "premium_budget": 100, + "option_target_points": 50, + "perp_target_points": 30, + "option_perp_ratio": 2, + "strike": 1900, + "index_px": 1905, + "ask": 10, + "moneyness": "atm", + "strike_interval": 15, + "min_option_hours": 36, + "hours_to_expiry": 40, + "option_leverage": 100, + } + self.assertIsNone(validate_option_primary_start(body)) + self.assertIsNone(validate_start_body("perp_options", body)) + bad = dict(body, opt_type="P") + self.assertIsNotNone(validate_start_body("perp_options", bad)) + + def test_preview_builds_scenarios(self): + body = { + "direction": "long", + "strike": 1900, + "option_target_points": 50, + "perp_target_points": 30, + "ask": 20, + "sheets": 10, + "ct_mult": 0.01, + "contracts": 5, + "contract_size": 0.01, + "entry": 1900, + "index_px": 1900, + "premium_budget": 100, + } + out = build_option_primary_preview(body) + self.assertTrue(out["option_primary"]) + self.assertEqual(len(out["scenarios"]), 2) + + +if __name__ == "__main__": + unittest.main()