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:
@@ -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`
|
||||
@@ -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 ≤ K−N`)后立即执行:
|
||||
|
||||
| 触达 | 规则 |
|
||||
|------|------|
|
||||
| **期权目标** | 验买一流动性 + **扣费净利 > 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
@@ -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
@@ -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;期权目标需买一且扣费净利>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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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≤K−N.点数须 >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,
|
||||
}
|
||||
@@ -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, ""):
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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+永续多;左填权利金/杠杆/比例/目标点数;开仓先期权后市价永续;期权目标验买一且净利>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>
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user