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:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Vendored
+15
@@ -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]] = [
|
||||
|
||||
@@ -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
|
||||
@@ -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 "未知计划类型"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user