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()
|