diff --git a/docs/对冲计划-选约与虚实值.md b/docs/对冲计划-选约与虚实值.md new file mode 100644 index 0000000..d517371 --- /dev/null +++ b/docs/对冲计划-选约与虚实值.md @@ -0,0 +1,74 @@ +# 对冲计划 · 选约与虚实值 + +> 实现日:2026-08-05 · 吸收 `eth_hedge_sim`(比特骆驼)选约几何;退出仍用本仓 TP/SL/S*,**不**移植仿真「净盈亏 15U 离场」。 + +## 1. 冻结规则 + +| 计划类型 | 允许虚实值 | 禁止 | 推荐模板 | +|----------|------------|------|----------| +| **永期** `perp_options` | 实值、平值 | **虚值** | 距指数最近的实值/平值(同方向 Put/Call) | +| **期期** `options_options` | 平值、虚值 | **实值** | 平值跨式(ATM C+P);双虚值(OTM C+P) | + +口径与 `lib/options/options_pricing_lib.option_moneyness` 一致:ATM 带 = `max(指数×0.2%, 2U)`。 + +永期几何兜底(与仿真一致): + +- Call 实值/平值:`K ≤ S` +- Put 实值/平值:`K ≥ S` + +## 2. 代码落点 + +| 层 | 文件 | 作用 | +|----|------|------| +| 选约/校验库 | `lib/hedge_plan/hedge_plan_moneyness_lib.py` | `is_itm_or_atm` / `is_atm_or_otm` / `pick_*` / `recommend_oo_legs` / `validate_*` | +| 启动门禁 | `hedge_plan_orders_lib.validate_start_body` | 测算外再拦一遍;防绕过 UI 直 POST | +| 测算 | `hedge_plan_register._preview_po/_preview_oo` | 预览同样拒绝违规腿 | +| UI | `hedge_plan.js` + `hedge_plan_panel.html` | 筛选锁定、推荐按钮、选用前校验 | +| Env | `env_ui_manifest` 永期分组 | `HEDGE_PLAN_ITM_MAX_DIST_USD` / `MIN_OPTION_HOURS` / `MIN_OPTION_LEVERAGE` | + +## 3. Env + +| 键 | 默认 | 说明 | +|----|------|------| +| `HEDGE_PLAN_ITM_MAX_DIST_USD` | 空→沿用 `OKX_OPTIONS_ITM_MAX_DIST_USD`(常 30) | 永期过深实值上限;0=不限 | +| `HEDGE_PLAN_MIN_OPTION_HOURS` | 8 | 仅当请求带 `hours_to_expiry` 时生效 | +| `HEDGE_PLAN_MIN_OPTION_LEVERAGE` | 0 | `指数/卖一`;0=关闭 | + +## 4. 可用性审计 + +| 项 | 结论 | +|----|------| +| 默认筛选 | 永期默认「实值/平值」;期期默认「平/虚」—减少误选 | +| 推荐一键 | 永期「推荐」;期期「推荐跨式 / 推荐双虚」—降低手选成本 | +| 文案 | 规则说明与 alert 明确禁虚(永期)/禁实(期期) | +| 服务端一致 | UI 过滤可绕过时,preview/start 仍会 400 | +| 兼容旧 API | 未传 `strike` 时从 `inst_id` 解析;未传 `index_px` 时永期用 `entry`、期期用上下破中点 | +| 未移植 | 仿真净盈亏 15U 离场、固定方向自动轮换到期 — 故意不接,避免与本仓 TP/SL 冲突 | + +**已知局限:** + +- 链上 `moneyness` 依赖刷新时指数;剧烈跳动后需「刷新链」再选。 +- `MIN_OPTION_HOURS` 需前端/调用方传入 `hours_to_expiry` 才校验(当前链行未必带该字段)。 +- 期期「推荐跨式」优先 ATM,若无 ATM 会回退到最近允许档(含 OTM)。 + +## 5. 安全性审计 + +| 风险 | 控制 | +|------|------| +| 客户端改包选虚值永期保险 | `validate_start_body` + preview 服务端拒绝 | +| 客户端选实值期期腿 | 同上 | +| 过深实值权利金过贵 / 杠杆过低 | `ITM_MAX_DIST` + 可选 `MIN_OPTION_LEVERAGE` | +| 误开实盘 | 既有 `HEDGE_PLAN_LIVE_ORDER` ∩ `LIVE_TRADING_ENABLED` ∩ 全仓(永期)门禁不变 | +| 本改动是否改平仓路径 | **否**;不触碰现有持仓、不改 TP/SL 监控逻辑 | + +## 6. 测试 + +```bash +python -m unittest tests.test_hedge_plan_moneyness tests.test_hedge_plan_orders -v +``` + +覆盖:虚实值几何、永期拒 OTM、期期拒 ITM、`validate_start_body` 集成。 + +## 7. 与开发方案对齐 + +更新 `docs/对冲计划开发方案.md` §3.2 / §4.1 选约约束,与本文件一致。 diff --git a/docs/对冲计划开发方案.md b/docs/对冲计划开发方案.md index 4fcc41a..450e91d 100644 --- a/docs/对冲计划开发方案.md +++ b/docs/对冲计划开发方案.md @@ -75,6 +75,8 @@ - 行情自动拉 OKX 期权链(复用 `build_option_chain`). - **报价形态:列表式**;多仓默认筛 **Put**,空仓默认筛 **Call**. +- **虚实值(冻结):**仅允许 **实值或平值**,**禁止虚值**(保险腿须有内在价值或贴近平值).详见 [对冲计划-选约与虚实值.md](./对冲计划-选约与虚实值.md). +- 页面默认筛「实值/平值」,提供「推荐」取距指数最近档;服务端 `validate_start_body` / preview 二次校验. - 权利金默认按 **卖一 ask** 估算;开仓限价买入. ### 3.3 左右布局 @@ -93,7 +95,8 @@ - **T 型报价链**(复用期权页 T 型样式/数据结构). - 用户选 **腿 A + 腿 B**(通常 Call + Put,或主方向 + 尾部). -- 预算:`B = min(交易户 USDC × OKX_OPTIONS_BUDGET_BUFFER, OKX_OPTIONS_TRADE_BUDGET_USDC)`(默认 buffer=0.95). +- **虚实值(冻结):**两腿仅允许 **平值或虚值**,**禁止实值**;推荐模板:平值跨式 / 双虚值.详见 [对冲计划-选约与虚实值.md](./对冲计划-选约与虚实值.md). +- 预算:`B = min(交易户 USDC × 对冲缓冲 HEDGE_PLAN_BUDGET_BUFFER, 单笔预算)`(默认 buffer=0.95;与期权页 buffer 独立). - 自动张数(选齐两腿后写入,可手改): - **同张数**(默认):最大 `n` 使 `n×(cost_A+cost_B) ≤ B`,两腿均填 `n` - **做多 / 做空**:须一 Call 一 Put;主:次默认 **7:3**(`HEDGE_PLAN_OO_BIAS_RATIO`,可改) diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index d2b5f33..3095132 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -22,7 +22,8 @@ tab: pickDefaultTab(), mode: showPerp ? "perp_options" : showOo ? "options_options" : "perp_options", underlying: root.getAttribute("data-default-underly") || "ETH", - moneyFilter: "all", + moneyFilter: "itm", // 永期锁定:实值+平值 + ooMoneyFilter: "atm_otm", // 期期锁定:平值+虚值 chain: null, selected: null, legA: null, @@ -144,12 +145,83 @@ } function matchesMoneyFilter(c) { - const f = state.moneyFilter || "all"; - if (f === "all") return true; + // 永期:仅实值/平值(禁虚值) + const f = state.moneyFilter || "itm"; const m = (c.moneyness || "").toLowerCase(); if (f === "itm") return m === "itm" || m === "atm"; + if (f === "atm") return m === "atm"; if (f === "otm") return m === "otm"; - return true; + return m === "itm" || m === "atm"; + } + + function matchesOoMoneyFilter(c) { + // 期期:仅平值/虚值(禁实值) + if (!c) return false; + const m = (c.moneyness || "").toLowerCase(); + const f = state.ooMoneyFilter || "atm_otm"; + if (f === "atm") return m === "atm"; + if (f === "otm") return m === "otm"; + return m === "atm" || m === "otm"; + } + + function indexPx() { + const fromChain = state.chain && Number(state.chain.index_px); + if (fromChain && !Number.isNaN(fromChain) && fromChain > 0) return fromChain; + const fromMkt = state.market && Number(state.market.index_px || state.market.mark); + if (fromMkt && !Number.isNaN(fromMkt) && fromMkt > 0) return fromMkt; + return null; + } + + function currentContracts(expSelectId) { + const exp = currentExp(expSelectId); + return (exp && exp.contracts) || []; + } + + function pickClosestItmAtm(contracts, want) { + const idx = indexPx(); + if (!idx) return null; + const list = (contracts || []).filter(function (c) { + return ( + String(c.opt_type || "").toUpperCase() === want && + matchesMoneyFilter(c) + ); + }); + if (!list.length) return null; + list.sort(function (a, b) { + return Math.abs(Number(a.strike) - idx) - Math.abs(Number(b.strike) - idx); + }); + return list[0]; + } + + function pickOoTemplate(template) { + const idx = indexPx(); + const contracts = currentContracts("hp-oo-exp-select"); + if (!idx || !contracts.length) return null; + const preferOtm = template === "double_otm"; + function pickSide(want) { + const list = contracts.filter(function (c) { + if (String(c.opt_type || "").toUpperCase() !== want) return false; + if (!matchesOoMoneyFilter(c)) return false; + const m = (c.moneyness || "").toLowerCase(); + if (preferOtm) return m === "otm"; + return m === "atm" || m === "otm"; + }); + if (!list.length) return null; + list.sort(function (a, b) { + const ma = (a.moneyness || "").toLowerCase(); + const mb = (b.moneyness || "").toLowerCase(); + if (!preferOtm) { + if (ma === "atm" && mb !== "atm") return -1; + if (mb === "atm" && ma !== "atm") return 1; + } + return Math.abs(Number(a.strike) - idx) - Math.abs(Number(b.strike) - idx); + }); + return list[0]; + } + const call = pickSide("C"); + const put = pickSide("P"); + if (!call || !put) return null; + return { call: call, put: put }; } function optTypeForDirection(dir) { @@ -174,6 +246,10 @@ const on = b.getAttribute("data-money") === state.moneyFilter; b.classList.toggle("active", on); }); + document.querySelectorAll(".hp-oo-money-btn").forEach(function (b) { + const on = b.getAttribute("data-oo-money") === state.ooMoneyFilter; + b.classList.toggle("active", on); + }); } function syncTabUI() { @@ -705,6 +781,11 @@ function pickContract(c) { if (!c) return; + const m = (c.moneyness || "").toLowerCase(); + if (m === "otm") { + alert("永期保险腿须为实值或平值,不可选虚值"); + return; + } state.selected = c; const el = $("hp-sel-inst"); if (el) el.textContent = c.inst_id; @@ -815,15 +896,18 @@ const tr = document.createElement("tr"); const call = row.call; const put = row.put; - const callAsk = call ? fmtPxSz(call.ask, call.ask_sz, call.ask_estimated, call.tick_sz) : "—"; - const putAsk = put ? fmtPxSz(put.ask, put.ask_sz, put.ask_estimated, put.tick_sz) : "—"; + const callOk = call && matchesOoMoneyFilter(call); + const putOk = put && matchesOoMoneyFilter(put); + if (!callOk && !putOk) return; + const callAsk = callOk ? fmtPxSz(call.ask, call.ask_sz, call.ask_estimated, call.tick_sz) : "—"; + const putAsk = putOk ? fmtPxSz(put.ask, put.ask_sz, put.ask_estimated, put.tick_sz) : "—"; tr.innerHTML = '' + callAsk + '' + - (call ? moneynessBadge(call) : "—") + + (callOk ? moneynessBadge(call) : "—") + "" + - (call + (callOk ? '' @@ -831,11 +915,11 @@ '' + row.strike + '' + - (put ? moneynessBadge(put) : "—") + + (putOk ? moneynessBadge(put) : "—") + '' + putAsk + "" + - (put + (putOk ? '' @@ -850,7 +934,10 @@ const c = (exp2.contracts || []).find(function (x) { return x.inst_id === inst; }); - if (!c) return; + if (!c || !matchesOoMoneyFilter(c)) { + alert("期期仅可选平值或虚值,不可选实值"); + return; + } if (!state.legA) state.legA = c; else if (!state.legB || state.legB.inst_id === state.legA.inst_id) state.legB = c; else { @@ -981,6 +1068,9 @@ let body; if (isOo) { if (!state.legA || !state.legB) throw new Error("请选用两条期权腿"); + if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { + throw new Error("期期两腿须为平值或虚值,不可选实值"); + } const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0); const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0); if (!up || !down) throw new Error("请填写上破与下破目标价"); @@ -990,12 +1080,14 @@ target_price_up: up, target_price_down: down, target_price: up, - index_px: (state.chain && state.chain.index_px) || (up + down) / 2, + index_px: indexPx() || (up + down) / 2, leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), }; } 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); @@ -1015,7 +1107,7 @@ sheets: sheets, ct_mult: state.selected.ct_mult || 0.01, ask: state.selected.ask, - index_px: state.chain && state.chain.index_px, + index_px: indexPx() || entry, }; } const d = await apiJson("/api/hedge-plan/preview", { @@ -1152,11 +1244,68 @@ }); document.querySelectorAll(".hp-money-btn").forEach(function (b) { b.addEventListener("click", function () { - state.moneyFilter = b.getAttribute("data-money") || "all"; + const m = b.getAttribute("data-money") || "itm"; + // 永期禁止选虚值筛选 + if (m === "otm") { + alert("永期保险腿仅允许实值或平值"); + return; + } + state.moneyFilter = m === "atm" ? "atm" : "itm"; syncMoneyUI(); renderListStrikes(); }); }); + document.querySelectorAll(".hp-oo-money-btn").forEach(function (b) { + b.addEventListener("click", function () { + const m = b.getAttribute("data-oo-money") || "atm_otm"; + if (m === "itm") { + alert("期期两腿仅允许平值或虚值"); + return; + } + state.ooMoneyFilter = m; + syncMoneyUI(); + renderTStrikes(); + }); + }); + if ($("hp-recommend-opt")) { + $("hp-recommend-opt").addEventListener("click", function () { + const want = optTypeForDirection(getDirection()); + const c = pickClosestItmAtm(currentContracts("hp-exp-select"), want); + if (!c) { + alert("当前到期日无可用实值/平值合约,请换到期或刷新链"); + return; + } + pickContract(c); + }); + } + if ($("hp-oo-recommend-atm")) { + $("hp-oo-recommend-atm").addEventListener("click", function () { + const pair = pickOoTemplate("atm_straddle"); + if (!pair) { + alert("无法推荐平值跨式,请确认到期日与链数据"); + return; + } + state.legA = pair.call; + state.legB = pair.put; + renderOoLegs(); + autoFillOoSheets(); + updateOoPremiumLine(); + }); + } + if ($("hp-oo-recommend-otm")) { + $("hp-oo-recommend-otm").addEventListener("click", function () { + const pair = pickOoTemplate("double_otm"); + if (!pair) { + alert("无法推荐双虚值,请确认到期日与链数据"); + return; + } + state.legA = pair.call; + state.legB = pair.put; + renderOoLegs(); + autoFillOoSheets(); + updateOoPremiumLine(); + }); + } document.querySelectorAll(".hp-po-dir").forEach(function (b) { b.addEventListener("click", function () { setDirection(b.getAttribute("data-dir") || "long", true); @@ -1734,6 +1883,9 @@ let body; if (isOo) { if (!state.legA || !state.legB) throw new Error("请选用两条期权腿"); + if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { + throw new Error("期期两腿须为平值或虚值,不可选实值"); + } const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0); const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0); if (!up || !down) throw new Error("请填写上破与下破目标价"); @@ -1744,6 +1896,7 @@ target_price_up: up, target_price_down: down, target_price: up, + index_px: indexPx() || (up + down) / 2, oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry", oo_sheets_mode: state.ooSheetsMode || "same_sheets", leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), @@ -1751,6 +1904,8 @@ }; } 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); @@ -1769,6 +1924,8 @@ 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, diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 1912d28..7125b1c 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -188,6 +188,21 @@ _HEDGE_PO_FIELDS: list[tuple[str, str, str]] = [ ("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"), ("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"), ("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"), + ( + "HEDGE_PLAN_ITM_MAX_DIST_USD", + "永期实值最大深度(U)", + "默认空=沿用 OKX_OPTIONS_ITM_MAX_DIST_USD(常 30);0=不限制", + ), + ( + "HEDGE_PLAN_MIN_OPTION_HOURS", + "对冲期权最低剩余小时", + "默认 8;测算/启动时若传 hours_to_expiry 则校验", + ), + ( + "HEDGE_PLAN_MIN_OPTION_LEVERAGE", + "对冲期权最低杠杆(S/ask)", + "默认 0=不启用;>0 时拒绝杠杆过低的保险腿", + ), ] _HEDGE_OO_FIELDS: list[tuple[str, str, str]] = [ diff --git a/lib/hedge_plan/hedge_plan_moneyness_lib.py b/lib/hedge_plan/hedge_plan_moneyness_lib.py new file mode 100644 index 0000000..94856b9 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_moneyness_lib.py @@ -0,0 +1,285 @@ +"""对冲计划虚实值选约与校验. + +永期(perp_options):期权腿仅允许实值或平值(禁虚值). +期期(options_options):两腿仅允许平值或虚值(禁实值). +""" +from __future__ import annotations + +import os +from typing import Any, Optional + + +def _env_float(name: str, default: float) -> float: + try: + return float(os.getenv(name) or default) + except (TypeError, ValueError): + return float(default) + + +def itm_max_dist_usd() -> float: + """过深实值上限(USD).优先对冲专用,否则回退期权页.""" + raw = (os.getenv("HEDGE_PLAN_ITM_MAX_DIST_USD") or "").strip() + if raw: + try: + return max(0.0, float(raw)) + except ValueError: + pass + return max(0.0, _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0)) + + +def min_option_hours() -> float: + return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_HOURS", 8.0)) + + +def min_option_leverage() -> float: + """指数/卖一 最低杠杆门槛;0=不启用.""" + return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_LEVERAGE", 0.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 normalize_opt_type(opt_type: Any, inst_id: str = "") -> str: + o = str(opt_type or "").strip().upper() + if o in ("C", "CALL"): + return "C" + if o in ("P", "PUT"): + return "P" + inst = str(inst_id or "").upper() + if inst.endswith("-C") or inst.endswith("-CALL"): + return "C" + if inst.endswith("-P") or inst.endswith("-PUT"): + return "P" + return "" + + +def classify_moneyness(*, opt_type: str, strike: float, index_px: float) -> str: + """itm / atm / otm / unknown.与 options_pricing_lib.option_moneyness 同口径.""" + from lib.options.options_pricing_lib import option_moneyness + + return option_moneyness(opt_type=opt_type, strike=strike, index_px=index_px) + + +def is_itm_or_atm(*, opt_type: str, strike: float, index_px: float) -> bool: + """Call: K<=S(+atm 带);Put: K>=S.用 classify 结果含 atm/itm.""" + m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px) + if m in ("itm", "atm"): + return True + # 几何兜底(与 eth_hedge_sim 一致),避免 atm 带边界漏判 + o = normalize_opt_type(opt_type) + k = float(strike) + s = float(index_px) + if o == "C": + return k <= s + 1e-9 + if o == "P": + return k >= s - 1e-9 + return False + + +def is_atm_or_otm(*, opt_type: str, strike: float, index_px: float) -> bool: + m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px) + if m in ("atm", "otm"): + return True + o = normalize_opt_type(opt_type) + k = float(strike) + s = float(index_px) + if o == "C": + return k >= s - 1e-9 # 平值带内或虚值 + if o == "P": + return k <= s + 1e-9 + return False + + +def itm_depth_usd(*, opt_type: str, strike: float, index_px: float) -> float: + o = normalize_opt_type(opt_type) + k = float(strike) + s = float(index_px) + if o == "C" and k < s: + return s - k + if o == "P" and k > s: + return k - s + return 0.0 + + +def parse_strike_from_inst(inst_id: str) -> Optional[float]: + """从 OKX 合约名解析行权价: ETH-USD-260731-1800-P.""" + parts = str(inst_id or "").strip().upper().split("-") + if len(parts) < 5: + return None + return _sf(parts[-2]) + + +def pick_itm_or_atm_contract( + contracts: list[dict[str, Any]], + *, + opt_type: str, + index_px: float, + itm_max_dist: Optional[float] = None, +) -> Optional[dict[str, Any]]: + """在合约列表中选距标的最近的实值/平值腿.""" + want = normalize_opt_type(opt_type) + if not want or index_px <= 0: + return None + max_dist = itm_max_dist if itm_max_dist is not None else itm_max_dist_usd() + cands: list[tuple[float, float, dict[str, Any]]] = [] + for c in contracts or []: + if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want: + continue + k = _sf(c.get("strike")) + if k is None: + continue + if not is_itm_or_atm(opt_type=want, strike=k, index_px=index_px): + continue + depth = itm_depth_usd(opt_type=want, strike=k, index_px=index_px) + if max_dist > 0 and depth > max_dist: + continue + cands.append((abs(k - index_px), k, c)) + if not cands: + return None + cands.sort(key=lambda x: (x[0], x[1])) + return cands[0][2] + + +def pick_atm_or_otm_contract( + contracts: list[dict[str, Any]], + *, + opt_type: str, + index_px: float, + prefer: str = "atm", +) -> Optional[dict[str, Any]]: + """选平值或虚值腿.prefer=atm 取距标的最近;prefer=otm 取最近虚值(不含实值).""" + want = normalize_opt_type(opt_type) + if not want or index_px <= 0: + return None + prefer_l = (prefer or "atm").strip().lower() + cands: list[tuple[float, float, dict[str, Any]]] = [] + for c in contracts or []: + if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want: + continue + k = _sf(c.get("strike")) + if k is None: + continue + if not is_atm_or_otm(opt_type=want, strike=k, index_px=index_px): + continue + m = classify_moneyness(opt_type=want, strike=k, index_px=index_px) + if prefer_l == "otm" and m != "otm": + continue + if prefer_l == "atm" and m == "otm": + # 仍可入选,但排序靠后(先 atm) + cands.append((1_000_000 + abs(k - index_px), k, c)) + else: + cands.append((abs(k - index_px), k, c)) + if not cands: + return None + cands.sort(key=lambda x: (x[0], x[1])) + return cands[0][2] + + +def recommend_oo_legs( + contracts: list[dict[str, Any]], + *, + index_px: float, + template: str = "atm_straddle", +) -> Optional[tuple[dict[str, Any], dict[str, Any]]]: + """期期推荐两腿.atm_straddle=最近平值 Call+Put;double_otm=最近虚值 Call+Put.""" + tpl = (template or "atm_straddle").strip().lower() + prefer = "otm" if tpl in ("double_otm", "otm_otm", "otm") else "atm" + call = pick_atm_or_otm_contract( + contracts, opt_type="C", index_px=index_px, prefer=prefer + ) + put = pick_atm_or_otm_contract( + contracts, opt_type="P", index_px=index_px, prefer=prefer + ) + if not call or not put: + return None + if str(call.get("inst_id") or "") == str(put.get("inst_id") or ""): + return None + return call, put + + +def validate_po_option_moneyness( + *, + opt_type: str, + strike: Any, + index_px: Any, + ask: Any = None, + hours_to_expiry: Any = None, +) -> Optional[str]: + """永期保险腿校验;返回错误文案或 None.""" + 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 "行权价或指数无效,无法校验虚实值" + if not is_itm_or_atm(opt_type=o, strike=k, index_px=s): + return "永期保险腿须为实值或平值,不可选虚值" + max_dist = itm_max_dist_usd() + depth = itm_depth_usd(opt_type=o, strike=k, index_px=s) + if max_dist > 0 and depth > max_dist: + return f"实值过深(距现价 {depth:.1f}U > {max_dist:.0f}U),请换更接近平值的档" + min_h = min_option_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" + min_lev = min_option_leverage() + a = _sf(ask) + 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_oo_leg_moneyness( + *, + opt_type: str, + strike: Any, + index_px: Any, + role: str = "腿", +) -> Optional[str]: + o = normalize_opt_type(opt_type) + k = _sf(strike) + s = _sf(index_px) + if o not in ("C", "P"): + return f"{role}期权类型无效" + if k is None or s is None or s <= 0: + return f"{role}行权价或指数无效,无法校验虚实值" + m = classify_moneyness(opt_type=o, strike=k, index_px=s) + if m == "itm": + return f"{role}须为平值或虚值,不可选实值" + if not is_atm_or_otm(opt_type=o, strike=k, index_px=s): + return f"{role}须为平值或虚值" + return None + + +def validate_oo_legs_moneyness( + leg_a: dict[str, Any], + leg_b: dict[str, Any], + *, + index_px: Any, +) -> Optional[str]: + err = validate_oo_leg_moneyness( + opt_type=leg_a.get("opt_type"), + strike=leg_a.get("strike"), + index_px=index_px, + role="腿A", + ) + if err: + return err + err = validate_oo_leg_moneyness( + opt_type=leg_b.get("opt_type"), + strike=leg_b.get("strike"), + index_px=index_px, + role="腿B", + ) + if err: + return err + return None diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index 63cf389..4ec4750 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -965,6 +965,26 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: return "做多须满足 止损 < 入场 < 止盈" if direction == "short" and not (tp < entry < sl): return "做空须满足 止盈 < 入场 < 止损" + from lib.hedge_plan.hedge_plan_moneyness_lib import ( + parse_strike_from_inst, + validate_po_option_moneyness, + ) + + 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") + if index_px in (None, ""): + index_px = entry + money_err = validate_po_option_moneyness( + opt_type=opt_type, + strike=strike, + index_px=index_px, + ask=body.get("ask"), + hours_to_expiry=body.get("hours_to_expiry"), + ) + if money_err: + return money_err return None if pt == "options_options": a = body.get("leg_a") or {} @@ -985,6 +1005,37 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: return "上破目标价必须大于下破目标价" except (TypeError, ValueError): return "目标价无效" + from lib.hedge_plan.hedge_plan_moneyness_lib import ( + parse_strike_from_inst, + validate_oo_legs_moneyness, + ) + + def _leg_for_money(leg: dict) -> dict: + strike = leg.get("strike") + if strike in (None, ""): + strike = parse_strike_from_inst(str(leg.get("inst_id") or "")) + opt_type = leg.get("opt_type") + if not opt_type: + inst = str(leg.get("inst_id") or "").upper() + if inst.endswith("-C"): + opt_type = "C" + elif inst.endswith("-P"): + opt_type = "P" + return {"opt_type": opt_type, "strike": strike} + + index_px = body.get("index_px") + if index_px in (None, ""): + try: + index_px = (float(up) + float(down)) / 2.0 + except (TypeError, ValueError): + index_px = None + money_err = validate_oo_legs_moneyness( + _leg_for_money(a), + _leg_for_money(b), + index_px=index_px, + ) + if money_err: + return money_err return None return "未知计划类型" diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index 3989bf7..027e1a4 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -891,6 +891,8 @@ 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 + direction = str(body.get("direction") or "long").lower() entry = float(body["entry"]) tp = float(body["tp"]) @@ -908,6 +910,16 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]: raise ValueError("缺少权利金或卖一价") premium = option_premium_total(ask=float(ask), sheets=sheets, ct_mult=ct_mult) index_px = body.get("index_px") + idx_for_money = float(index_px) if index_px is not None else entry + money_err = validate_po_option_moneyness( + opt_type=opt_type, + strike=strike, + index_px=idx_for_money, + ask=ask, + hours_to_expiry=body.get("hours_to_expiry"), + ) + if money_err: + raise ValueError(money_err) return build_perp_options_preview( direction=direction, entry=entry, @@ -925,6 +937,8 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]: def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: + from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness + up = body.get("target_price_up") down = body.get("target_price_down") legacy = body.get("target_price") @@ -952,6 +966,9 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: ) if leg.get("premium_paid") is None: raise ValueError(f"缺少 {name} 权利金") + money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px) + if money_err: + raise ValueError(money_err) return build_options_options_preview( target_price_up=up_f, target_price_down=down_f, diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index 82db747..36f622f 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -49,7 +49,7 @@

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

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

-

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

+

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

@@ -91,9 +91,9 @@

期权 · Put 期权账户

- - - + + + 指数 —
@@ -135,7 +135,7 @@

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

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

-

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

+

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

@@ -185,6 +185,11 @@

期权 T 型报价

+ + + + +
diff --git a/tests/test_hedge_plan_moneyness.py b/tests/test_hedge_plan_moneyness.py new file mode 100644 index 0000000..49b7e24 --- /dev/null +++ b/tests/test_hedge_plan_moneyness.py @@ -0,0 +1,119 @@ +"""对冲计划虚实值选约与校验.""" +import unittest + +from lib.hedge_plan.hedge_plan_moneyness_lib import ( + is_atm_or_otm, + is_itm_or_atm, + parse_strike_from_inst, + pick_itm_or_atm_contract, + recommend_oo_legs, + validate_oo_legs_moneyness, + validate_po_option_moneyness, +) +from lib.hedge_plan.hedge_plan_orders_lib import validate_start_body + + +class TestHedgeMoneyness(unittest.TestCase): + def test_parse_strike(self): + self.assertEqual(parse_strike_from_inst("ETH-USD-260731-1800-P"), 1800.0) + self.assertIsNone(parse_strike_from_inst("bad")) + + def test_po_geometry(self): + # Call: K<=S 实/平 + self.assertTrue(is_itm_or_atm(opt_type="C", strike=3500, index_px=3510)) + self.assertFalse(is_itm_or_atm(opt_type="C", strike=3600, index_px=3510)) + # Put: K>=S + self.assertTrue(is_itm_or_atm(opt_type="P", strike=3600, index_px=3510)) + self.assertFalse(is_itm_or_atm(opt_type="P", strike=3400, index_px=3510)) + + def test_oo_geometry(self): + self.assertTrue(is_atm_or_otm(opt_type="C", strike=3600, index_px=3510)) + self.assertFalse(is_atm_or_otm(opt_type="C", strike=3400, index_px=3510)) + self.assertTrue(is_atm_or_otm(opt_type="P", strike=3400, index_px=3510)) + self.assertFalse(is_atm_or_otm(opt_type="P", strike=3600, index_px=3510)) + + def test_validate_po_rejects_otm(self): + err = validate_po_option_moneyness(opt_type="P", strike=1700, index_px=1800) + self.assertIsNotNone(err) + self.assertIn("虚值", err) + self.assertIsNone( + validate_po_option_moneyness(opt_type="P", strike=1800, index_px=1800) + ) + + def test_validate_oo_rejects_itm(self): + err = validate_oo_legs_moneyness( + {"opt_type": "C", "strike": 1700}, + {"opt_type": "P", "strike": 1900}, + index_px=1800, + ) + self.assertIsNotNone(err) + self.assertIn("实值", err) + self.assertIsNone( + validate_oo_legs_moneyness( + {"opt_type": "C", "strike": 1850}, + {"opt_type": "P", "strike": 1750}, + index_px=1800, + ) + ) + + def test_pick_itm_or_atm(self): + contracts = [ + {"inst_id": "E-C-3600", "opt_type": "C", "strike": 3600}, + {"inst_id": "E-C-3500", "opt_type": "C", "strike": 3500}, + {"inst_id": "E-C-3400", "opt_type": "C", "strike": 3400}, + ] + picked = pick_itm_or_atm_contract( + contracts, opt_type="C", index_px=3510, itm_max_dist=200 + ) + self.assertIsNotNone(picked) + self.assertEqual(picked["strike"], 3500) + + def test_recommend_oo(self): + contracts = [ + {"inst_id": "E-C-1800", "opt_type": "C", "strike": 1800}, + {"inst_id": "E-P-1800", "opt_type": "P", "strike": 1800}, + {"inst_id": "E-C-1900", "opt_type": "C", "strike": 1900}, + {"inst_id": "E-P-1700", "opt_type": "P", "strike": 1700}, + ] + pair = recommend_oo_legs(contracts, index_px=1800, template="atm_straddle") + self.assertIsNotNone(pair) + self.assertEqual(pair[0]["opt_type"], "C") + self.assertEqual(pair[1]["opt_type"], "P") + + def test_validate_start_body_po_otm(self): + err = validate_start_body( + "perp_options", + { + "direction": "long", + "entry": 1800, + "tp": 1900, + "sl": 1700, + "contracts": 1, + "opt_inst_id": "ETH-USD-260731-1700-P", + "opt_type": "P", + "strike": 1700, + "index_px": 1800, + "sheets": 1, + "exchange_symbol": "ETH/USDT:USDT", + }, + ) + self.assertIsNotNone(err) + self.assertIn("虚值", err) + + def test_validate_start_body_oo_itm(self): + err = validate_start_body( + "options_options", + { + "target_price_up": 1900, + "target_price_down": 1700, + "index_px": 1800, + "leg_a": {"inst_id": "ETH-USD-260731-1700-C", "opt_type": "C", "strike": 1700}, + "leg_b": {"inst_id": "ETH-USD-260731-1900-P", "opt_type": "P", "strike": 1900}, + }, + ) + self.assertIsNotNone(err) + self.assertIn("实值", err) + + +if __name__ == "__main__": + unittest.main()