Lock hedge-plan option selection to ITM/ATM (perp) and ATM/OTM (OO).

Server validate on preview/start, UI filters and recommend templates, plus usability/security audit doc.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-05 21:50:35 +08:00
parent 09a763e47d
commit 3f6e67661b
9 changed files with 746 additions and 20 deletions
+74
View File
@@ -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 选约约束,与本文件一致。
+4 -1
View File
@@ -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`,可改)
+171 -14
View File
@@ -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 =
'<td class="opt-t-call opt-px-sz">' +
callAsk +
'</td><td class="opt-t-call">' +
(call ? moneynessBadge(call) : "—") +
(callOk ? moneynessBadge(call) : "—") +
"</td><td>" +
(call
(callOk
? '<button type="button" class="btn-secondary hp-oo-pick" data-side="C" data-inst="' +
call.inst_id +
'">Call</button>'
@@ -831,11 +915,11 @@
'</td><td class="opt-t-strike"><strong>' +
row.strike +
'</strong></td><td class="opt-t-put">' +
(put ? moneynessBadge(put) : "—") +
(putOk ? moneynessBadge(put) : "—") +
'</td><td class="opt-t-put opt-px-sz">' +
putAsk +
"</td><td>" +
(put
(putOk
? '<button type="button" class="btn-secondary hp-oo-pick" data-side="P" data-inst="' +
put.inst_id +
'">Put</button>'
@@ -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,
+15
View File
@@ -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]] = [
+285
View File
@@ -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
+51
View File
@@ -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 "未知计划类型"
+17
View File
@@ -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,
+10 -5
View File
@@ -49,7 +49,7 @@
<div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);保险期权走<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p>
<p><strong>下单</strong>:先「计算」再「启动」。启动瞬间会再拉卖一并以 IOC 等完全成交;半腿失败可补开或「结束计划」(不平仓)。永期开仓需全仓计仓 + 对冲实盘门禁。</p>
<p><strong>板块</strong>:左填永续开仓/止盈止损与张数;右选保险腿(做多配 Put、做空配 Call)。止盈后保险腿默认可持有;止损会联动平期权。</p>
<p><strong>板块</strong>:左填永续开仓/止盈止损与张数;右选保险腿(做多配 Put、做空配 Call)。<strong>保险腿仅允许实值或平值</strong>(禁虚值)。止盈后保险腿默认可持有;止损会联动平期权。</p>
</div>
</details>
<div class="form-row hp-uly-row">
@@ -91,9 +91,9 @@
<h2>期权 · <span id="hp-opt-type-label">Put</span> <span class="muted hp-acct-tag" id="hp-opt-acct-tag">期权账户</span></h2>
<div class="form-row hp-opt-toolbar hp-po-opt-toolbar">
<select id="hp-exp-select"><option value="">选择到期日</option></select>
<button type="button" class="btn-secondary hp-money-btn active" data-money="all">全部</button>
<button type="button" class="btn-secondary hp-money-btn" data-money="itm"></button>
<button type="button" class="btn-secondary hp-money-btn" data-money="otm">虚值</button>
<button type="button" class="btn-secondary hp-money-btn active" data-money="itm" title="实值+平值">实值/平值</button>
<button type="button" class="btn-secondary hp-money-btn" data-money="atm" title="仅平值">仅平</button>
<button type="button" class="btn-secondary" id="hp-recommend-opt" title="选距指数最近的实值/平值">推荐</button>
<button type="button" class="btn-secondary" id="hp-load-chain">刷新链</button>
<span id="hp-index-line" class="hp-po-index" aria-live="polite">指数 —</span>
</div>
@@ -135,7 +135,7 @@
<div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p>
<p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
<p><strong>板块</strong>:左填上破/下破与张数模式(同张数/做多/做空);右 T 型选腿。「全平」= 盈利腿平后清另一腿;「到期平」= 另一腿持有至到期。</p>
<p><strong>板块</strong>:左填上破/下破与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。「全平」= 盈利腿平后清另一腿;「到期平」= 另一腿持有至到期。</p>
</div>
</details>
<div class="form-row hp-uly-row">
@@ -185,6 +185,11 @@
<h2>期权 T 型报价</h2>
<div class="form-row">
<select id="hp-oo-exp-select"><option value="">选择到期日</option></select>
<button type="button" class="btn-secondary hp-oo-money-btn active" data-oo-money="atm_otm" title="平值+虚值">平/虚</button>
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="atm" title="仅平值">仅平值</button>
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="otm" title="仅虚值">仅虚值</button>
<button type="button" class="btn-secondary" id="hp-oo-recommend-atm" title="最近平值 Call+Put">推荐跨式</button>
<button type="button" class="btn-secondary" id="hp-oo-recommend-otm" title="最近虚值 Call+Put">推荐双虚</button>
<button type="button" class="btn-secondary" id="hp-oo-load-chain">刷新链</button>
</div>
<div class="options-strike-table-wrap options-strike-table-wrap--t">
+119
View File
@@ -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()