feat(hedge): add option-primary mode for perp+options plans

Add UI switch for Call+short/Put+long, premium x0.95 sizing, option-first open, and K+/-points exits with fee-aware net PnL.

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