From 5d161b4220cbc6bc468e5694b4dda9f89f2fe393 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 14 Oct 2026 20:05:25 +0800 Subject: [PATCH] Add HEDGE_PO_MODE instance modes with shared sim/live path. Lock one mode per instance/subaccount, cross-asset BTC option/ETH perp sizing, combo-U exit, and ship docs/tests/audit. Co-authored-by: Cursor --- .env.example | 5 + README.md | 3 + app.py | 14 +- docs/永期对冲三模式.md | 137 ++++++++ lib/common/static/hedge_plan.js | 141 ++++++--- lib/common/static/instance_theme.css | 16 + lib/env/env_schema.py | 7 + lib/env/env_ui_manifest.py | 14 +- lib/hedge_plan/hedge_plan_db.py | 3 + lib/hedge_plan/hedge_plan_monitor_lib.py | 40 ++- .../hedge_plan_option_primary_lib.py | 135 +++++++- lib/hedge_plan/hedge_plan_orders_lib.py | 96 ++++-- lib/hedge_plan/hedge_plan_register.py | 114 +++++-- lib/hedge_plan/hedge_po_mode_lib.py | 296 ++++++++++++++++++ .../templates/hedge_plan_panel.html | 25 +- scripts/audit_hedge_po_mode.py | 91 ++++++ tests/test_hedge_plan_option_primary.py | 16 +- tests/test_hedge_po_mode.py | 177 +++++++++++ 18 files changed, 1215 insertions(+), 115 deletions(-) create mode 100644 docs/永期对冲三模式.md create mode 100644 lib/hedge_plan/hedge_po_mode_lib.py create mode 100644 scripts/audit_hedge_po_mode.py create mode 100644 tests/test_hedge_po_mode.py diff --git a/.env.example b/.env.example index c3a76c5..eab69bb 100644 --- a/.env.example +++ b/.env.example @@ -164,6 +164,11 @@ HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true HEDGE_PLAN_LIVE_ORDER=false # 永期子模式:true=以期权为主;false=保险模式(页面标题前标识,不可页内切换) HEDGE_PLAN_OPTION_PRIMARY=true +# 永期实例模式(一实例一种;对应一个子账户;模拟/实盘同路径;改后须重启 PM2) +# eth=ETH期权+ETH永续 | btc=BTC期权+BTC永续 | btc_eth=BTC期权+ETH永续(名义×K) +HEDGE_PO_MODE=eth +# 仅 btc_eth:永续名义 = 期权名义 × K(默认 0.5);比例=1/K +HEDGE_CROSS_NOTIONAL_K=0.5 HEDGE_PLAN_OPEN_ORDER=options_first HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false diff --git a/README.md b/README.md index 213f5bd..ddda40a 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,13 @@ python app.py - `SIM_DEFAULT_MODE=sim`(默认模拟盘) - `OKX_OPTIONS_MARGIN_MODE=coin`(固定) - `OKX_TRADE_MODE=perp_options|options_options`(本仓仅对冲) +- `HEDGE_PO_MODE=eth|btc|btc_eth`(一实例一模式一子账户;跨币见 `HEDGE_CROSS_NOTIONAL_K`) - `STANDALONE_OPTIONS_UI=false`(默认;勿开,除非临时救急) - `SIM_INITIAL_EQUITY_USDT=10000` - `APP_PORT=5010` +永期三模式说明与审计清单见 [docs/永期对冲三模式.md](docs/永期对冲三模式.md)。 + ## 风险 实盘风险自负。默认模拟盘;切 `live` 前请核对 API 权限与资金。 diff --git a/app.py b/app.py index 1e503e7..8b59056 100644 --- a/app.py +++ b/app.py @@ -6878,6 +6878,9 @@ def render_main_page(page="trade", embed_mode=None): _standalone_options_ui = standalone_options_ui_enabled() _hedge_mode_on = _okx_trade_mode in ("perp_options", "options_options") _show_perp_funds = show_perp_funds_enabled(exchange_key="okx") + from lib.hedge_plan.hedge_po_mode_lib import public_mode_payload as _po_mode_public + + _hedge_po_mode_payload = _po_mode_public() template_ctx = dict( page=page, key=key_list, @@ -7000,7 +7003,16 @@ def render_main_page(page="trade", embed_mode=None): options_compound_full_cap_usdc=float( os.getenv("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC") or "300" ), - options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY, + options_default_underly=_hedge_po_mode_payload.get("option_underlying") + or OKX_OPTIONS_DEFAULT_UNDERLY, + hedge_po_mode=_hedge_po_mode_payload.get("po_mode"), + hedge_po_mode_label=_hedge_po_mode_payload.get("po_mode_label"), + hedge_po_mode_short=_hedge_po_mode_payload.get("po_mode_short"), + hedge_option_underlying=_hedge_po_mode_payload.get("option_underlying"), + hedge_perp_underlying=_hedge_po_mode_payload.get("perp_underlying"), + hedge_cross_asset=bool(_hedge_po_mode_payload.get("cross_asset")), + hedge_cross_notional_k=_hedge_po_mode_payload.get("cross_notional_k"), + hedge_option_perp_ratio=_hedge_po_mode_payload.get("option_perp_ratio"), options_chain_ask_liq_filter=os.getenv( "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true" ).lower() diff --git a/docs/永期对冲三模式.md b/docs/永期对冲三模式.md new file mode 100644 index 0000000..c7b29d5 --- /dev/null +++ b/docs/永期对冲三模式.md @@ -0,0 +1,137 @@ +# 永期对冲三模式(一实例 · 一模式 · 一子账户) + +> 模拟盘与实盘 **同一执行路径**;差别仅在 API Key / `LIVE_TRADING_ENABLED` / 模拟开关。 +> 实现入口:`lib/hedge_plan/hedge_po_mode_lib.py`。 + +--- + +## 1. 定稿口径 + +| 模式 `HEDGE_PO_MODE` | 期权标的 | 永续标的 | 名义关系 | +|----------------------|----------|----------|----------| +| `eth` | ETH | ETH | 永续名义 = 期权名义 ÷ 比例(默认比例 2 → 一半) | +| `btc` | BTC | BTC | 同上 | +| `btc_eth` | BTC | ETH | 永续名义(U) = 期权名义(U) × `HEDGE_CROSS_NOTIONAL_K`(默认 **0.5**) | + +### 方向(以期权为主,强制) + +- 买 **Call** → 永续 **空** +- 买 **Put** → 永续 **多** + +### 出场(强制) + +- 仅 **组合盈利金额(U)** +- 口径:`期权净利(币) × 期权指数 + 永续净利(U)`(扣开平双边手续费) +- 与持仓卡 `combo.net_usdt`、监控出场门控一致;**禁止**币与 U 直接相加 + +### 部署约束 + +- **一个实例一种模式对应一个子账户**;账户之间互不干涉 +- **禁止**同一子账户挂两个实例 +- **禁止**运行中热切 `HEDGE_PO_MODE`(须换实例 / 换子账户 / 重启) +- 模拟 → 实盘:同模式、同定仓/出场逻辑,只换密钥与 live/sim 开关 + +--- + +## 2. 环境变量 + +```env +# 必配(建议显式写出);非法值启动时钳制并打日志 +HEDGE_PO_MODE=eth # eth | btc | btc_eth +HEDGE_CROSS_NOTIONAL_K=0.5 # 仅 btc_eth;永续名义/期权名义 + +# 会与模式同步:期权链默认标的 = 模式的期权腿 +OKX_OPTIONS_DEFAULT_UNDERLY=ETH + +# 以期权为主(实例模式会强制 true) +HEDGE_PLAN_OPTION_PRIMARY=true + +# 真下单:模拟可开;实盘须再开 LIVE_TRADING_ENABLED +HEDGE_PLAN_LIVE_ORDER=true +LIVE_TRADING_ENABLED=false +``` + +改 `HEDGE_PO_MODE` / `HEDGE_CROSS_NOTIONAL_K` 后须 **重启 PM2**(启动时 `clamp_hedge_po_mode_on_startup`)。 + +--- + +## 3. 多实例部署示例 + +| 进程 / 域名 | `.env` | 子账户 | +|-------------|--------|--------| +| 实例 A | `HEDGE_PO_MODE=eth` | OKX 子账户「ETH对冲」 | +| 实例 B | `HEDGE_PO_MODE=btc` | OKX 子账户「BTC对冲」 | +| 实例 C | `HEDGE_PO_MODE=btc_eth` | OKX 子账户「跨币」 | + +模拟盘同样拆三套(或三库),**不要**一个模拟进程里切三种模式共一把钥匙。 + +--- + +## 4. 代码落点 + +| 模块 | 作用 | +|------|------| +| `hedge_po_mode_lib.py` | 模式规范化、启动钳制、跨币名义、组合 U、body 钳制 | +| `hedge_plan_option_primary_lib.py` | `size_from_premium` / `contracts_from_option_sheets` 支持双指数 | +| `hedge_plan_orders_lib.py` | 启动前定仓刷新、校验按模式钳制 | +| `hedge_plan_monitor_lib.py` | 出场门控用组合 U;跨币指数分腿 | +| `hedge_plan_register.py` | 安装时钳制;行情/期权链锁定标的;落库 `po_mode`/`perp_underlying` | +| `hedge_plan.js` + panel | 页内锁定标的;展示模式徽标;强制盈利金额出场 | +| env schema / UI manifest | `HEDGE_PO_MODE`、`HEDGE_CROSS_NOTIONAL_K` | + +--- + +## 5. 测试 + +```bash +python -m unittest tests.test_hedge_po_mode tests.test_hedge_plan_option_primary -v +``` + +覆盖:模式规范化、启动钳制、跨币名义半仓、组合 U 口径、body 校验。 + +--- + +## 6. 审计清单(模拟 / 实盘通用) + +启动后日志应出现: + +```text +[HEDGE_PO_MODE] mode=... option=... perp=... k=... (一实例一模式一子账户) +``` + +| # | 检查项 | 期望 | +|---|--------|------| +| 1 | `/api/hedge-plan/po-mode` | `po_mode` / `option_underlying` / `perp_underlying` / `exit_mode=profit` | +| 2 | 对冲页顶栏徽标 | 显示模式名;跨币显示「期权BTC/永续ETH」 | +| 3 | ETH/BTC 切换钮 | 永期页禁用,与模式期权标的一致 | +| 4 | 期权链 | 仅模式期权标的;拉错标的 API 400 | +| 5 | 永续行情 `exchange_symbol` | 前缀=模式永续标的 | +| 6 | 手动开仓 body | 服务端重写 `underlying`/`exit_mode`/`option_perp_ratio`(跨币) | +| 7 | 定仓(跨币) | 期权名义U × K ≈ 永续名义U | +| 8 | 持仓卡 `combo.net_usdt` | 期权币×期权指数 + 永续U | +| 9 | 监控出场 | 达 `profit_target_usdt` 才平;比较的是组合 U | +| 10 | 卖回币 | 卖 **期权标的**币(跨币卖 BTC) | +| 11 | 模拟 vs 实盘 | 同一套 orders/monitor;仅 Key 与 live 开关不同 | +| 12 | 子账户 | 三模式三 Key;无交叉下单 | + +快速审计脚本: + +```bash +python scripts/audit_hedge_po_mode.py +``` + +--- + +## 7. 实盘上线顺序建议 + +1. 模拟实例 `HEDGE_PO_MODE=eth` 走通开平与组合 U 出场 +2. 同模式换实盘子账户 Key,`LIVE_TRADING_ENABLED=true` + `HEDGE_PLAN_LIVE_ORDER=true` +3. 再开 `btc` / `btc_eth` 独立实例灰度 + +--- + +## 8. 相关文档 + +- [对冲计划-以期权为主.md](./对冲计划-以期权为主.md) +- [对冲计划策略与P0校验.md](./对冲计划策略与P0校验.md) +- [对冲计划开发方案.md](./对冲计划开发方案.md) diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index 4f21d0c..8761950 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -18,11 +18,25 @@ return "perp_options"; } + const poMode = (root.getAttribute("data-po-mode") || "eth").toLowerCase(); + const optionUnderly = (root.getAttribute("data-option-underly") || root.getAttribute("data-default-underly") || "ETH").toUpperCase(); + const perpUnderly = (root.getAttribute("data-perp-underly") || optionUnderly).toUpperCase(); + const crossAsset = root.getAttribute("data-cross-asset") === "1"; + const lockedRatio = (function () { + const raw = root.getAttribute("data-option-perp-ratio"); + const n = raw != null && raw !== "" ? Number(raw) : NaN; + return !Number.isNaN(n) && n > 0 ? n : 2; + })(); + const state = { tab: pickDefaultTab(), mode: showPerp ? "perp_options" : showOo ? "options_options" : "perp_options", posTab: "live", - underlying: root.getAttribute("data-default-underly") || "ETH", + underlying: optionUnderly, + poMode: poMode, + optionUnderly: optionUnderly, + perpUnderly: perpUnderly, + crossAsset: crossAsset, moneyFilter: root.getAttribute("data-option-primary") !== "0" ? "all" : "itm", ooMoneyFilter: "atm_otm", // 期期锁定:平值+虚值 ooRecommend: null, // atm_straddle | double_otm | null @@ -41,9 +55,9 @@ ooCloseMode: "close_all", direction: "long", optionPrimary: root.getAttribute("data-option-primary") !== "0", - exitMode: "profit", // profit=盈利金额出场 | target=目标位出场 + exitMode: "profit", // 实例模式强制组合盈利金额(U) opLevTouched: false, - opRatioTouched: false, + opRatioTouched: crossAsset, // 跨币锁比例,视为已设定 perpLevTouched: false, tradingUsdc: null, fundingUsdc: null, @@ -58,6 +72,32 @@ previewPlanType: null, }; + function lockPoUnderlyingUI() { + const opt = state.optionUnderly || "ETH"; + document.querySelectorAll(".hp-uly-btn").forEach(function (b) { + const uly = (b.getAttribute("data-uly") || "").toUpperCase(); + const on = uly === opt; + b.classList.toggle("active", on); + b.disabled = true; + b.title = "由 HEDGE_PO_MODE 锁定(" + (root.getAttribute("data-po-mode-label") || poMode) + ")"; + }); + const badge = $("hp-po-mode-badge"); + if (badge) { + const perp = state.perpUnderly || opt; + badge.textContent = + (root.getAttribute("data-po-mode-label") || poMode) + + (crossAsset ? " · 期权" + opt + "/永续" + perp : ""); + } + const ratioEl = $("hp-opt-perp-ratio"); + if (ratioEl && lockedRatio > 0) { + ratioEl.value = String(lockedRatio); + if (crossAsset) { + ratioEl.readOnly = true; + ratioEl.title = "跨币模式由 HEDGE_CROSS_NOTIONAL_K 锁定"; + } + } + } + function getDirection() { return (state.direction || "long").toLowerCase() === "short" ? "short" : "long"; } @@ -421,7 +461,7 @@ function computeOpSizing(ask, ctMult, sheetsOverride) { const budget = numInput("hp-premium-budget", 0); - const ratio = numInput("hp-opt-perp-ratio", 2); + const ratio = numInput("hp-opt-perp-ratio", lockedRatio || 2); const cs = Number((state.market && state.market.contract_size) || 0.01); const usable = budget * 0.95; const a = Number(ask || 0); @@ -443,16 +483,32 @@ if (!(sheets > 0)) return null; eth = Math.round(sheets * ct * 100) / 100; } - const perpEth = eth / ratio; - const contracts = perpEth / cs; + // 跨币:期权币×期权指数×(1/ratio) / 永续指数 = 永续币 + let perpQty = eth / ratio; + let contracts = perpQty / cs; + let optNotional = null; + let perpNotional = null; + if (state.crossAsset) { + const optIdx = Number((state.chain && state.chain.index_px) || 0); + const perpIdx = Number((state.market && (state.market.index_px || state.market.mark)) || 0); + if (optIdx > 0 && perpIdx > 0) { + optNotional = eth * optIdx; + perpNotional = optNotional / ratio; + perpQty = perpNotional / perpIdx; + contracts = perpQty / cs; + } + } return { usable: usable, eth_qty: eth, - perp_qty: perpEth, + perp_qty: perpQty, sheets: sheets, contracts: contracts, premium_est: a > 0 ? a * sheets * ct : 0, ratio: ratio, + option_notional_usdt: optNotional, + perp_notional_usdt: perpNotional, + cross_asset: !!state.crossAsset, }; } @@ -483,24 +539,32 @@ function formatOpSizingHtml(sized, amtPrec) { if (!sized) return ""; - const coin = (state.underlying || "ETH").toUpperCase(); - const prec = amtPrec != null ? amtPrec : coin === "BTC" ? 4 : 2; - return ( + const optCoin = (state.optionUnderly || state.underlying || "ETH").toUpperCase(); + const perpCoin = (state.perpUnderly || state.underlying || "ETH").toUpperCase(); + const prec = amtPrec != null ? amtPrec : perpCoin === "BTC" ? 4 : 2; + let html = "期权 " + fmt(sized.eth_qty, 2) + " " + - coin + + optCoin + " / " + sized.sheets + " 张 → 永续 " + fmt(sized.perp_qty, 4) + " " + - coin + + perpCoin + " (" + fmt(sized.contracts, prec) + " 张) · 比例 " + - fmt(sized.ratio, 2) - ); + fmt(sized.ratio, 2); + if (sized.cross_asset && sized.option_notional_usdt != null) { + html += + " · 名义U " + + fmt(sized.option_notional_usdt, 0) + + "/" + + fmt(sized.perp_notional_usdt, 0); + } + return html; } function defaultPerpLeverage(uly) { @@ -1881,9 +1945,12 @@ async function loadMarket() { const dir = getDirection(); + // 永续行情按实例模式永续标的(跨币=ETH);期权链仍用 optionUnderly + const mktBase = + state.mode === "perp_options" ? state.perpUnderly || state.underlying : state.underlying; const d = await apiJson( "/api/hedge-plan/market?base=" + - encodeURIComponent(state.underlying) + + encodeURIComponent(mktBase) + "&direction=" + encodeURIComponent(dir) + "&option_primary=" + @@ -2523,7 +2590,12 @@ } function setUnderlying(uly, forceReload) { - const next = (uly || "ETH").toUpperCase(); + let next = (uly || "ETH").toUpperCase(); + // 永期:标的由 HEDGE_PO_MODE 锁定,页内不可切换 + if (state.mode === "perp_options") { + next = state.optionUnderly || next; + if (!forceReload && next === state.underlying) return; + } const changed = next !== state.underlying; state.underlying = next; syncUnderlyingUI(); @@ -2813,6 +2885,9 @@ }); document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (b) { b.addEventListener("click", function () { + if (b.classList.contains("hp-uly-btn") && state.mode === "perp_options") { + return; // 永期标的由实例模式锁定 + } setUnderlying(b.getAttribute("data-uly") || "ETH", true); }); }); @@ -3011,7 +3086,8 @@ } document.querySelectorAll(".hp-po-exit-mode").forEach(function (b) { b.addEventListener("click", function () { - state.exitMode = b.getAttribute("data-exit") === "target" ? "target" : "profit"; + // 实例模式强制组合盈利金额(U)出场 + state.exitMode = "profit"; syncExitModeUI(); updatePerpPnlHint(); }); @@ -3763,23 +3839,14 @@ setPoOrderErr("请选择期权并填写张数(≥1)", "hp-order-sheets"); throw new Error("请选择期权并填写张数(≥1)"); } - const exitMode = state.exitMode === "target" ? "target" : "profit"; + const exitMode = "profit"; let optPts = 0; let perpPts = 0; let profitTarget = 0; - if (exitMode === "profit") { - profitTarget = numInput("hp-profit-target", NaN); - if (!(profitTarget > 0)) { - setPoOrderErr("请填写目标盈利金额(USDT,扣双边手续费后净利)", "hp-profit-target"); - throw new Error("请填写目标盈利金额(USDT,扣双边手续费后净利)"); - } - } else { - optPts = numInput("hp-opt-target-pts", NaN); - perpPts = numInput("hp-perp-target-pts", NaN); - if (!(optPts > 0) || !(perpPts > 0)) { - setPoOrderErr("请填写期权/永续目标位点数(须大于0)", !(optPts > 0) ? "hp-opt-target-pts" : "hp-perp-target-pts"); - throw new Error("请填写期权/永续目标位点数(须大于0)"); - } + profitTarget = numInput("hp-profit-target", NaN); + if (!(profitTarget > 0)) { + setPoOrderErr("请填写目标盈利金额(USDT,组合净利折U)", "hp-profit-target"); + throw new Error("请填写目标盈利金额(USDT,组合净利折U)"); } if (!(state.market && state.market.exchange_symbol)) { setPoOrderErr("永续行情未就绪,请先点「刷新行情」"); @@ -3805,9 +3872,12 @@ market_order: 1, watch_entry: 0, exit_mode: exitMode, - underlying: state.underlying, + po_mode: state.poMode, + underlying: state.optionUnderly || state.underlying, + option_underlying: state.optionUnderly || state.underlying, + perp_underlying: state.perpUnderly || state.underlying, direction: getDirection(), - entry: entry, + entry: Number((state.market && state.market.mark) || entry), contracts: sized.contracts, sheets: sized.sheets, contract_size: (state.market && state.market.contract_size) || 0.01, @@ -3818,10 +3888,10 @@ ask: state.selected.ask, index_px: entry, exchange_symbol: (state.market && state.market.exchange_symbol) || "", - leverage: defaultPerpLeverage(state.underlying), + leverage: defaultPerpLeverage(state.perpUnderly || state.underlying), option_leverage: 0, premium_budget: Math.max(premEst / 0.95, premEst), - option_perp_ratio: numInput("hp-opt-perp-ratio", 2), + option_perp_ratio: numInput("hp-opt-perp-ratio", lockedRatio), option_target_points: optPts || 0, perp_target_points: perpPts || 0, profit_target_usdt: profitTarget || 0, @@ -3929,6 +3999,7 @@ if (state.tab === "perp_options") void refreshPoStrategyStatus(); } + lockPoUnderlyingUI(); syncTabUI(); syncUnderlyingUI(); syncMoneyUI(); diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index d8c6fd1..5eaa65a 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3625,6 +3625,22 @@ html[data-theme="light"] .opt-be-dist-down { flex-wrap: wrap; margin: 0 0 12px; } +.hedge-plan-page-wrap .hp-po-mode-badge { + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--text, #1a1a1a); + opacity: 0.85; +} +.hedge-plan-page-wrap .hp-uly-lock { + display: inline-flex; + gap: 6px; + align-items: center; +} +.hedge-plan-page-wrap .hp-uly-lock .hp-uly-btn:disabled { + opacity: 0.75; + cursor: not-allowed; +} .hedge-plan-page-wrap .hp-po-gate-inline { font-size: 0.82rem; line-height: 1.35; diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 0d3afaf..2baa228 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -109,6 +109,8 @@ HOT_RELOAD_EXACT = frozenset({ "MAX_ACTIVE_HEDGE_PLANS", "HEDGE_PLAN_LIVE_ORDER", "HEDGE_PLAN_OPTION_PRIMARY", + "HEDGE_PO_MODE", + "HEDGE_CROSS_NOTIONAL_K", "HEDGE_PLAN_OPEN_ORDER", "HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", @@ -177,6 +179,11 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = { ("true", "以期权为主"), ("false", "保险模式"), ), + "HEDGE_PO_MODE": ( + ("eth", "ETH永期(期权+永续同ETH)"), + ("btc", "BTC永期(期权+永续同BTC)"), + ("btc_eth", "跨币(BTC期权+ETH永续)"), + ), "SIM_DEFAULT_MODE": ( ("sim", "模拟盘"), ("live", "实盘"), diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index be8d532..643be46 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -279,10 +279,20 @@ _HEDGE_COMMON_FIELDS: list[tuple[str, str, str]] = [ ] _HEDGE_PO_FIELDS: list[tuple[str, str, str]] = [ + ( + "HEDGE_PO_MODE", + "永期实例模式", + "eth/btc/btc_eth;一实例一种模式对应一个子账户;模拟与实盘同路径;改后须重启", + ), + ( + "HEDGE_CROSS_NOTIONAL_K", + "跨币永续名义系数", + "仅 btc_eth;默认 0.5=永续名义为期权名义一半;改后须重启", + ), ( "HEDGE_PLAN_OPTION_PRIMARY", "永期模式(以期权为主/保险)", - "默认 true=以期权为主;false=保险模式;页面标题前显示标识,不可在页内切换", + "实例模式强制以期权为主;一般保持 true", ), ("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"), ("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"), @@ -364,6 +374,8 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = { "HEDGE_PLAN_OO_BIAS_RATIO": "0.7", "HEDGE_PLAN_BUDGET_BUFFER": "0.95", "HEDGE_PLAN_OPTION_PRIMARY": "true", + "HEDGE_PO_MODE": "eth", + "HEDGE_CROSS_NOTIONAL_K": "0.5", "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true", "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true", "OKX_OPTIONS_CLOSE_GATE_MODE": "premium", diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py index 53f7dee..a0194d9 100644 --- a/lib/hedge_plan/hedge_plan_db.py +++ b/lib/hedge_plan/hedge_plan_db.py @@ -90,6 +90,9 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None: # 出场:profit=盈利金额(扣双边费);target=目标位点数 _ensure_column(conn, "hedge_plans", "exit_mode", "TEXT") _ensure_column(conn, "hedge_plans", "profit_target_usdt", "REAL") + # 永期实例模式:eth|btc|btc_eth;跨币时 underlying=期权标的,perp_underlying=永续标的 + _ensure_column(conn, "hedge_plans", "po_mode", "TEXT") + _ensure_column(conn, "hedge_plans", "perp_underlying", "TEXT") _ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL") diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py index 3599ae0..1d924c0 100644 --- a/lib/hedge_plan/hedge_plan_monitor_lib.py +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -695,6 +695,7 @@ def _tick_po_option_primary( target_hit, ) from lib.hedge_plan.hedge_plan_orders_lib import _close_perp + from lib.hedge_plan.hedge_po_mode_lib import combo_net_usdt 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) @@ -714,7 +715,9 @@ def _tick_po_option_primary( profit_target = _plan_profit_target(plan) if strike is None or strike <= 0: return None - idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) + # 期权指数:计划 underlying(跨币=BTC);目标位同口径 + opt_uly = str(plan.get("underlying") or plan.get("option_underlying") or "ETH").upper() + idx = _index_px(cfg, opt_uly) if idx is None: return None @@ -743,6 +746,13 @@ def _tick_po_option_primary( mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) except Exception: mark = None + # 跨币:永续标记用 ETH,不可回退到 BTC 指数 + if mark is None: + perp_uly = str(plan.get("perp_underlying") or "").upper() + if not perp_uly and symbol: + perp_uly = symbol.split("/")[0].upper() + if perp_uly and perp_uly != opt_uly: + mark = _index_px(cfg, perp_uly) 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) @@ -787,10 +797,11 @@ def _tick_po_option_primary( ) perp_px = _sf(book.get("vwap")) or mark or entry - # 优先期权腿平仓(须买一流动性);盈利金额模式要求扣双边费后净利≥目标 + # 优先期权腿平仓(须买一流动性);盈利金额模式要求扣双边费后组合净利(U)≥目标 if hit_opt: liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=sheets) net = None + net_usdt = None if liq_ok: net = estimate_combo_net_pnl( view_side=view, @@ -806,17 +817,19 @@ def _tick_po_option_primary( contracts=contracts, contract_size=cs, ) + net_usdt = combo_net_usdt(net, option_index=float(idx)) if exit_mode == "profit": can_opt_exit = bool( - liq_ok and net is not None and float(net.get("net") or 0) + 1e-9 >= profit_target + liq_ok and net_usdt is not None and float(net_usdt) + 1e-9 >= profit_target ) else: - can_opt_exit = bool(liq_ok and net is not None and float(net.get("net") or 0) > 0) + # 目标位模式:组合 U 净利须 > 0(与持仓卡同口径) + can_opt_exit = bool(liq_ok and net_usdt is not None and float(net_usdt) > 0) if can_opt_exit: reason = "profit_target_usdt" if exit_mode == "profit" else "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"]) + opt_pnl_coin = _option_leg_pnl_after_close(cfg, opt, fallback=float(net["opt_net"])) elif not close_r.get("ok") or not close_r.get("fully_closed", True): notify_hedge( cfg, @@ -829,10 +842,12 @@ def _tick_po_option_primary( 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"]) + opt_pnl_coin = _option_leg_pnl_after_close(cfg, opt, fallback=float(net["opt_net"])) + # 币本位期权盈亏折 U(×期权指数),与持仓卡/出场门控同口径 + opt_pnl_u = round(float(opt_pnl_coin) * float(idx), 4) 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"]), + ("closed", reason, _now(), opt_pnl_u, opt["id"]), ) perp_close = _close_perp( cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False @@ -853,14 +868,14 @@ def _tick_po_option_primary( "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) + total = float(net_usdt) if net_usdt is not None else (opt_pnl_u + 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_options=opt_pnl_u, realized_pnl_total=round(total, 4), stats_bucket="opt_primary", closed_at=_now(), @@ -872,6 +887,7 @@ def _tick_po_option_primary( "close_reason": reason, "total": total, "net": net, + "net_usdt": net_usdt, "sell_coin": sell_r, } if exit_mode == "profit": @@ -881,16 +897,18 @@ def _tick_po_option_primary( "msg": ( liq_msg if not liq_ok - else f"净利未达目标 {profit_target:g}(当前 {float((net or {}).get('net') or 0):.2f})" + else f"组合净利(U)未达目标 {profit_target:g}(当前 {float(net_usdt or 0):.2f})" ), "net": net, + "net_usdt": net_usdt, } if not hit_perp: return { "plan_id": plan["id"], "skip": True, - "msg": (liq_msg if not liq_ok else "净利≤0,继续持有"), + "msg": (liq_msg if not liq_ok else "组合净利(U)≤0,继续持有"), "net": net, + "net_usdt": net_usdt, } if not hit_perp: diff --git a/lib/hedge_plan/hedge_plan_option_primary_lib.py b/lib/hedge_plan/hedge_plan_option_primary_lib.py index d131680..ef2a7f4 100644 --- a/lib/hedge_plan/hedge_plan_option_primary_lib.py +++ b/lib/hedge_plan/hedge_plan_option_primary_lib.py @@ -141,8 +141,14 @@ def size_from_premium( ratio: float, contract_size: float, exec_factor: float = PREMIUM_EXEC_FACTOR, + option_index: Optional[float] = None, + perp_index: Optional[float] = None, ) -> dict[str, Any]: - """权利金×0.95 → ETH 两位小数 → 期权张 → 永续跟比例.""" + """权利金×0.95 → 币两位小数 → 期权张 → 永续跟比例. + + 同币:永续币 = 期权币 / ratio. + 跨币(传入 option_index 与 perp_index):永续名义U = 期权名义U × (1/ratio). + """ budget = float(premium_budget or 0) a = float(ask or 0) ct = float(ct_mult or 0.01) @@ -155,23 +161,25 @@ def size_from_premium( "msg": "定仓参数无效", "usable_premium": round(usable, 4), "eth_qty": 0.0, + "coin_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: + # ask 为每 1 币权利金;币数量 = usable / ask + coin_qty = floor2(usable / a) + if coin_qty <= 0: return { "ok": False, - "msg": "权利金不足以买入 0.01 ETH 名义期权", + "msg": "权利金不足以买入 0.01 币名义期权", "usable_premium": round(usable, 4), "eth_qty": 0.0, + "coin_qty": 0.0, "sheets": 0.0, "perp_eth": 0.0, "contracts": 0.0, } - sheets = eth_qty / ct + sheets = coin_qty / ct # 张数向下取整到整数张(OKX 期权常见整张) sheets_i = float(math.floor(sheets + 1e-12)) if sheets_i <= 0: @@ -179,27 +187,132 @@ def size_from_premium( "ok": False, "msg": "换算期权张数不足 1 张", "usable_premium": round(usable, 4), - "eth_qty": eth_qty, + "eth_qty": coin_qty, + "coin_qty": coin_qty, "sheets": 0.0, "perp_eth": 0.0, "contracts": 0.0, } - # 用整张回写 ETH,保持与下单一致 - eth_qty = round(sheets_i * ct, 2) - perp_eth = eth_qty / r + # 用整张回写币量,保持与下单一致 + coin_qty = round(sheets_i * ct, 2) + oi = _sf(option_index) + pi = _sf(perp_index) + cross = oi is not None and pi is not None and oi > 0 and pi > 0 and abs(oi - pi) > 1e-6 + if cross or (oi is not None and pi is not None and oi > 0 and pi > 0): + from lib.hedge_plan.hedge_po_mode_lib import size_cross_notional + + cross_sz = size_cross_notional( + option_coin_qty=coin_qty, + option_index=float(oi), + perp_index=float(pi), + contract_size=cs, + notional_k=(1.0 / r) if r > 0 else None, + ) + if not cross_sz.get("ok"): + return { + "ok": False, + "msg": cross_sz.get("msg") or "跨币定仓失败", + "usable_premium": round(usable, 4), + "eth_qty": coin_qty, + "coin_qty": coin_qty, + "sheets": sheets_i, + "perp_eth": 0.0, + "contracts": 0.0, + } + perp_coin = float(cross_sz["perp_coin"]) + contracts = float(cross_sz["contracts"]) + premium_est = a * sheets_i * ct + return { + "ok": True, + "msg": "", + "usable_premium": round(usable, 4), + "eth_qty": coin_qty, + "coin_qty": coin_qty, + "sheets": sheets_i, + "perp_eth": round(perp_coin, 6), + "contracts": contracts, + "premium_est": round(premium_est, 4), + "ratio": r, + "exec_factor": float(exec_factor or PREMIUM_EXEC_FACTOR), + "option_notional_usdt": cross_sz.get("option_notional_usdt"), + "perp_notional_usdt": cross_sz.get("perp_notional_usdt"), + "notional_k": cross_sz.get("notional_k"), + "cross_asset": bool(cross), + } + perp_eth = coin_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, + "eth_qty": coin_qty, + "coin_qty": coin_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), + "cross_asset": False, + } + + +def contracts_from_option_sheets( + *, + sheets: float, + ct_mult: float, + ratio: float, + contract_size: float, + option_index: Optional[float] = None, + perp_index: Optional[float] = None, +) -> dict[str, Any]: + """手动张数 → 永续张数(同币或跨币名义).""" + sheets_i = float(int(float(sheets or 0))) + ct = float(ct_mult or 0.01) + r = float(ratio or 0) + cs = float(contract_size or 0.01) + if sheets_i <= 0 or ct <= 0 or r <= 0 or cs <= 0: + return {"ok": False, "msg": "定仓参数无效", "coin_qty": 0.0, "contracts": 0.0} + coin_qty = round(sheets_i * ct, 2) + oi = _sf(option_index) + pi = _sf(perp_index) + if oi is not None and pi is not None and oi > 0 and pi > 0: + from lib.hedge_plan.hedge_po_mode_lib import size_cross_notional + + cross_sz = size_cross_notional( + option_coin_qty=coin_qty, + option_index=float(oi), + perp_index=float(pi), + contract_size=cs, + notional_k=(1.0 / r) if r > 0 else None, + ) + if not cross_sz.get("ok"): + return {"ok": False, "msg": cross_sz.get("msg") or "跨币定仓失败", "coin_qty": coin_qty} + return { + "ok": True, + "msg": "", + "sheets": sheets_i, + "coin_qty": coin_qty, + "eth_qty": coin_qty, + "contracts": float(cross_sz["contracts"]), + "perp_coin": float(cross_sz["perp_coin"]), + "option_notional_usdt": cross_sz.get("option_notional_usdt"), + "perp_notional_usdt": cross_sz.get("perp_notional_usdt"), + "ratio": r, + "cross_asset": abs(float(oi) - float(pi)) > 1e-6, + } + perp_coin = coin_qty / r + return { + "ok": True, + "msg": "", + "sheets": sheets_i, + "coin_qty": coin_qty, + "eth_qty": coin_qty, + "contracts": perp_coin / cs, + "perp_coin": perp_coin, + "ratio": r, + "cross_asset": False, } diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index 1de51e9..e9142cb 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -777,6 +777,16 @@ def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, An if q.get("ct_mult") is not None: body["ct_mult"] = float(q.get("ct_mult") or 0.01) if is_option_primary(body): + from lib.hedge_plan.hedge_plan_option_primary_lib import contracts_from_option_sheets + from lib.hedge_plan.hedge_po_mode_lib import ( + apply_mode_to_start_body, + get_hedge_po_mode, + is_cross_asset, + option_underlying, + perp_underlying, + ) + + body.update(apply_mode_to_start_body(body)) cs = float(body.get("contract_size") or 0.01) get_cs = cfg.get("get_contract_size") sym = str(body.get("exchange_symbol") or "") @@ -789,20 +799,41 @@ def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, An ct = float(body.get("ct_mult") or q.get("ct_mult") or 0.01) ratio = float(body.get("option_perp_ratio") or 2) body["contract_size"] = cs + opt_idx = None + perp_idx = None + try: + from lib.exchange.okx_public_http_lib import fetch_index_px + + mode = str(body.get("po_mode") or get_hedge_po_mode()) + opt_idx = float(fetch_index_px(option_underlying(mode)) or 0) or None + if is_cross_asset(mode): + perp_idx = float(fetch_index_px(perp_underlying(mode)) or 0) or None + else: + perp_idx = opt_idx + except Exception: + opt_idx = perp_idx = None + # 页面传入的 entry/index 作回退 + if opt_idx is None: + try: + opt_idx = float(body.get("index_px") or body.get("entry") or 0) or None + except (TypeError, ValueError): + opt_idx = None + if perp_idx is None: + perp_idx = opt_idx if market and float(body.get("sheets") or 0) > 0: - # 手动下单:以张数为准,永续按比例 - sheets_i = float(int(float(body["sheets"]))) - eth = round(sheets_i * ct, 2) - body["sheets"] = sheets_i - body["eth_qty"] = eth - body["contracts"] = (eth / ratio) / cs if ratio > 0 and cs > 0 else 0 - sized = { - "ok": True, - "sheets": sheets_i, - "eth_qty": eth, - "contracts": body["contracts"], - "ratio": ratio, - } + sized = contracts_from_option_sheets( + sheets=float(body["sheets"]), + ct_mult=ct, + ratio=ratio, + contract_size=cs, + option_index=opt_idx, + perp_index=perp_idx, + ) + if not sized.get("ok"): + return {"ok": False, "msg": sized.get("msg") or "定仓失败", "quote": q, "sizing": sized} + body["sheets"] = sized["sheets"] + body["eth_qty"] = sized.get("coin_qty") or sized.get("eth_qty") + body["contracts"] = sized["contracts"] else: sized = size_from_premium( premium_budget=float(body.get("premium_budget") or 0), @@ -810,21 +841,39 @@ def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, An ct_mult=ct, ratio=ratio, contract_size=cs, + option_index=opt_idx, + perp_index=perp_idx, ) 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["eth_qty"] = sized.get("coin_qty") or sized["eth_qty"] # 深度不足则缩量 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"]) * ct, 2) - body["eth_qty"] = eth - body["contracts"] = (eth / ratio) / cs if ratio > 0 and cs > 0 else 0 + resized = contracts_from_option_sheets( + sheets=float(body["sheets"]), + ct_mult=ct, + ratio=ratio, + contract_size=cs, + option_index=opt_idx, + perp_index=perp_idx, + ) + if not resized.get("ok"): + return {"ok": False, "msg": resized.get("msg") or "缩量定仓失败", "quote": q} + body["eth_qty"] = resized.get("coin_qty") or resized.get("eth_qty") + body["contracts"] = resized["contracts"] + sized = resized + cross_note = "" + if sized.get("cross_asset") or sized.get("option_notional_usdt"): + cross_note = ( + f" · 名义U 期权{float(sized.get('option_notional_usdt') or 0):.0f}" + f"/永续{float(sized.get('perp_notional_usdt') or 0):.0f}" + ) return { "ok": True, "ask": float(q["ask"]), @@ -837,10 +886,12 @@ def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, An "msg": ( f"手动定仓: {body.get('eth_qty')}币 / " f"{body.get('sheets')}张期权 / {float(body.get('contracts') or 0):.4f}张永续 @{q['ask']}" + f"{cross_note}" if market else ( - f"期权为主定仓: 权利金×0.95→{body.get('eth_qty')}ETH / " + f"期权为主定仓: 权利金×0.95→{body.get('eth_qty')}币 / " f"{body.get('sheets')}张期权 / {float(body.get('contracts') or 0):.4f}张永续 @{q['ask']}" + f"{cross_note}" ) ), } @@ -1184,7 +1235,16 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: is_option_primary, validate_option_primary_start, ) + from lib.hedge_plan.hedge_po_mode_lib import ( + apply_mode_to_start_body, + validate_body_matches_mode, + ) + # 模拟/实盘同路径:启动前按实例模式钳制 + body.update(apply_mode_to_start_body(body)) + mode_err = validate_body_matches_mode(body) + if mode_err: + return mode_err if is_option_primary(body): from lib.hedge_plan.hedge_plan_option_primary_lib import validate_option_primary_watch diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index 0cf362e..b1fbc6b 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -39,8 +39,13 @@ def attach_hedge_plan_templates(app: Flask, repo_root: str) -> None: def install_hedge_plan(app: Flask, repo_root: str, app_module: Any) -> None: + from lib.hedge_plan.hedge_po_mode_lib import clamp_hedge_po_mode_on_startup, public_mode_payload + attach_hedge_plan_templates(app, repo_root) + mode_summary = clamp_hedge_po_mode_on_startup() cfg = _build_cfg(app_module) + cfg["po_mode"] = mode_summary.get("mode") + cfg["po_mode_payload"] = public_mode_payload(mode_summary.get("mode")) app.extensions["hedge_plan_cfg"] = cfg register_hedge_plan_routes(app, cfg) _maybe_start_monitor(cfg) @@ -98,6 +103,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "chain_max_dte": float(os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") or "14"), "perp_account_label": (os.getenv("OKX_ACCOUNT_LABEL") or "合约账户").strip(), "options_account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "期权账户").strip(), + "po_mode": (os.getenv("HEDGE_PO_MODE") or "").strip().lower(), "trade_budget_usdc": float( os.getenv("OKX_OPTIONS_COIN_BUDGET_USDT") or os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC") @@ -318,16 +324,25 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any perp_direction_for_view, ) + from lib.hedge_plan.hedge_po_mode_lib import ( + apply_mode_to_start_body, + get_hedge_po_mode, + option_underlying, + perp_underlying, + ) + + body = apply_mode_to_start_body(body) 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) ) + po_mode = str(body.get("po_mode") or get_hedge_po_mode()) plan_row = { "plan_type": "perp_options", "status": "partial" if is_partial else "active", - "underlying": str(body.get("underlying") or "ETH").upper(), + "underlying": str(body.get("underlying") or option_underlying(po_mode)).upper(), "direction": view, "entry_mark": float(body.get("entry") or 0), "tp": float(body.get("tp") or 0) if not opt_primary else 0, @@ -343,11 +358,12 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any "note": (result.get("msg") or "")[:500] if is_partial else None, "option_primary": 1 if opt_primary else 0, "perp_direction": perp_dir, + "po_mode": po_mode, + "perp_underlying": str(body.get("perp_underlying") or perp_underlying(po_mode)).upper(), } if opt_primary: - exit_mode = str(body.get("exit_mode") or "target").strip().lower() - if exit_mode not in ("profit", "target"): - exit_mode = "target" + # 实例模式强制组合盈利金额出场 + exit_mode = "profit" plan_row.update( { "option_target_points": float(body.get("option_target_points") or 0), @@ -410,18 +426,26 @@ def _persist_po_watching(cfg: dict[str, Any], body: dict[str, Any]) -> int: """以期权为主:只落库盯盘计划,不下单.""" from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_plan from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view + from lib.hedge_plan.hedge_po_mode_lib import ( + apply_mode_to_start_body, + get_hedge_po_mode, + option_underlying, + perp_underlying, + ) + body = apply_mode_to_start_body(body) conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) view = str(body.get("direction") or "long") money = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower() + po_mode = str(body.get("po_mode") or get_hedge_po_mode()) plan_id = insert_plan( conn, { "plan_type": "perp_options", "status": "watching", - "underlying": str(body.get("underlying") or "ETH").upper(), + "underlying": str(body.get("underlying") or option_underlying(po_mode)).upper(), "direction": view, "entry_mark": float(body.get("index_px") or body.get("entry") or 0) or None, "tp": 0, @@ -445,6 +469,10 @@ def _persist_po_watching(cfg: dict[str, Any], body: dict[str, Any]) -> int: "min_option_hours": float(body.get("min_option_hours") or 36), "option_moneyness": money, "option_leverage": float(body.get("option_leverage") or 0), + "exit_mode": "profit", + "profit_target_usdt": float(body.get("profit_target_usdt") or 0), + "po_mode": po_mode, + "perp_underlying": str(body.get("perp_underlying") or perp_underlying(po_mode)).upper(), }, ) conn.commit() @@ -650,41 +678,51 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: @app.route("/api/hedge-plan/market") @lr def api_hedge_market(): - base = (request.args.get("base") or cfg.get("default_underly") or "ETH").strip().upper() - if base not in ("BTC", "ETH"): - return jsonify({"ok": False, "msg": "对冲计划仅支持 BTC/ETH"}), 400 + from lib.hedge_plan.hedge_po_mode_lib import ( + get_hedge_po_mode, + option_underlying, + perp_underlying, + public_mode_payload, + ) + + mode = get_hedge_po_mode() + mode_payload = public_mode_payload(mode) + # 永续行情按实例模式的永续标的;跨币忽略前端乱传的 base + base = perp_underlying(mode) + req_base = (request.args.get("base") or "").strip().upper() + if req_base in ("BTC", "ETH") and req_base == base: + pass + elif req_base and req_base != base: + # 兼容旧前端:仍返回正确永续,并提示 + pass 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", - ) + # 实例模式强制以期权为主 + option_primary = True 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, - ) + 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" + suggested = opt_type_for_view(direction) + perp_dir = perp_direction_for_view(direction) + acct_note = ( + f"实例模式 {mode_payload['po_mode_label']}:期权 {option_underlying(mode)}" + f" + 永续 {base};一子账户互不干涉" + ) out = { "ok": True, "base": base, + "option_underlying": option_underlying(mode), + "perp_underlying": base, "direction": direction, - "option_primary": option_primary, + "option_primary": True, "suggested_opt_type": suggested, "perp_direction": perp_dir, **data, @@ -693,9 +731,17 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: "account_kind": "perp", "account_label": cfg.get("perp_account_label") or "合约账户", "account_note": acct_note, + **mode_payload, } return jsonify(out) + @app.route("/api/hedge-plan/po-mode") + @lr + def api_hedge_po_mode(): + from lib.hedge_plan.hedge_po_mode_lib import public_mode_payload + + return jsonify({"ok": True, **public_mode_payload()}) + @app.route("/api/hedge-plan/options-chain") @lr def api_hedge_options_chain(): @@ -704,7 +750,19 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: ex = cfg.get("exchange_options") if ex is None: return jsonify({"ok": False, "msg": "期权交易所未初始化"}), 400 - u = (request.args.get("underlying") or cfg.get("default_underly") or "ETH").upper() + from lib.hedge_plan.hedge_po_mode_lib import get_hedge_po_mode, option_underlying + + # 期权链锁定实例模式的期权标的(跨币=BTC) + u = option_underlying(get_hedge_po_mode()) + req_u = (request.args.get("underlying") or "").strip().upper() + if req_u in ("BTC", "ETH") and req_u != u: + return jsonify( + { + "ok": False, + "msg": f"本实例模式期权标的为 {u},不可拉 {req_u} 链", + "option_underlying": u, + } + ), 400 # 热更新:链展示天数每次读 env chain_max_dte = float( os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") diff --git a/lib/hedge_plan/hedge_po_mode_lib.py b/lib/hedge_plan/hedge_po_mode_lib.py new file mode 100644 index 0000000..cda8e89 --- /dev/null +++ b/lib/hedge_plan/hedge_po_mode_lib.py @@ -0,0 +1,296 @@ +"""永期对冲实例模式(env: HEDGE_PO_MODE). + +一实例一种模式,对应一个 OKX 子账户;模拟盘与实盘同一执行路径. + +eth → 期权 ETH + 永续 ETH +btc → 期权 BTC + 永续 BTC +btc_eth → 期权 BTC + 永续 ETH;永续名义 = 期权名义 × HEDGE_CROSS_NOTIONAL_K(默认 0.5) + +方向(以期权为主):Call→永续空 / Put→永续多. +出场:组合盈利金额(U),扣开平双边手续费. +""" +from __future__ import annotations + +import logging +import os +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +MODE_ETH = "eth" +MODE_BTC = "btc" +MODE_BTC_ETH = "btc_eth" +VALID_PO_MODES = frozenset({MODE_ETH, MODE_BTC, MODE_BTC_ETH}) + +DEFAULT_CROSS_NOTIONAL_K = 0.5 + +_ALIASES = { + "eth_eth": MODE_ETH, + "e": MODE_ETH, + "eth-only": MODE_ETH, + "btc_btc": MODE_BTC, + "b": MODE_BTC, + "btc-only": MODE_BTC, + "cross": MODE_BTC_ETH, + "btc-eth": MODE_BTC_ETH, + "btc/eth": MODE_BTC_ETH, + "跨币": MODE_BTC_ETH, +} + + +def _sf(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def normalize_hedge_po_mode(raw: Optional[str]) -> str: + s = str(raw or "").strip().lower().replace("-", "_").replace("/", "_") + if s in VALID_PO_MODES: + return s + if s in _ALIASES: + return _ALIASES[s] + return "" + + +def infer_hedge_po_mode_from_underly() -> str: + u = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() + if u == "BTC": + return MODE_BTC + return MODE_ETH + + +def get_hedge_po_mode() -> str: + m = normalize_hedge_po_mode(os.getenv("HEDGE_PO_MODE")) + if m: + return m + return infer_hedge_po_mode_from_underly() + + +def cross_notional_k() -> float: + """跨币:永续名义 / 期权名义.默认 0.5.""" + v = _sf(os.getenv("HEDGE_CROSS_NOTIONAL_K")) + if v is None or v <= 0: + return DEFAULT_CROSS_NOTIONAL_K + # 合理钳制,避免误配把永续打到极端 + return max(0.05, min(float(v), 5.0)) + + +def default_option_perp_ratio(mode: Optional[str] = None) -> float: + """UI/定仓用比例:期权名义 ÷ 比例 = 永续名义 → ratio = 1/k.""" + m = mode or get_hedge_po_mode() + if m == MODE_BTC_ETH: + k = cross_notional_k() + return round(1.0 / k, 6) if k > 0 else 2.0 + return 2.0 + + +def option_underlying(mode: Optional[str] = None) -> str: + m = mode or get_hedge_po_mode() + if m in (MODE_BTC, MODE_BTC_ETH): + return "BTC" + return "ETH" + + +def perp_underlying(mode: Optional[str] = None) -> str: + m = mode or get_hedge_po_mode() + if m == MODE_BTC: + return "BTC" + return "ETH" + + +def is_cross_asset(mode: Optional[str] = None) -> bool: + return (mode or get_hedge_po_mode()) == MODE_BTC_ETH + + +def mode_label(mode: Optional[str] = None) -> str: + m = mode or get_hedge_po_mode() + return { + MODE_ETH: "ETH永期", + MODE_BTC: "BTC永期", + MODE_BTC_ETH: "跨币BTC期权/ETH永续", + }.get(m, m or "—") + + +def mode_short_label(mode: Optional[str] = None) -> str: + m = mode or get_hedge_po_mode() + return { + MODE_ETH: "ETH", + MODE_BTC: "BTC", + MODE_BTC_ETH: "BTC→ETH", + }.get(m, m or "—") + + +def clamp_hedge_po_mode_on_startup() -> dict[str, Any]: + """启动钳制:规范化 HEDGE_PO_MODE,并同步默认期权标的. + + 不写回 .env 文件(避免热更/多实例踩踏);仅 os.environ 进程内生效. + 返回审计摘要供日志/测试. + """ + raw = (os.getenv("HEDGE_PO_MODE") or "").strip() + normalized = normalize_hedge_po_mode(raw) if raw else "" + warnings: list[str] = [] + if raw and not normalized: + warnings.append(f"非法 HEDGE_PO_MODE={raw!r},回退推断") + mode = infer_hedge_po_mode_from_underly() + elif normalized: + mode = normalized + else: + mode = infer_hedge_po_mode_from_underly() + warnings.append(f"未配置 HEDGE_PO_MODE,按 OKX_OPTIONS_DEFAULT_UNDERLY 推断为 {mode}") + + os.environ["HEDGE_PO_MODE"] = mode + opt_u = option_underlying(mode) + prev_u = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "").strip().upper() + if prev_u != opt_u: + os.environ["OKX_OPTIONS_DEFAULT_UNDERLY"] = opt_u + if prev_u: + warnings.append(f"同步 OKX_OPTIONS_DEFAULT_UNDERLY {prev_u}→{opt_u}") + + k = cross_notional_k() + os.environ["HEDGE_CROSS_NOTIONAL_K"] = str(k) + summary = { + "ok": True, + "mode": mode, + "label": mode_label(mode), + "option_underlying": opt_u, + "perp_underlying": perp_underlying(mode), + "cross_notional_k": k, + "option_perp_ratio": default_option_perp_ratio(mode), + "warnings": warnings, + } + for w in warnings: + logger.warning("[HEDGE_PO_MODE] %s", w) + logger.info( + "[HEDGE_PO_MODE] mode=%s option=%s perp=%s k=%s (一实例一模式一子账户)", + mode, + opt_u, + summary["perp_underlying"], + k, + ) + return summary + + +def apply_mode_to_start_body(body: dict[str, Any], *, mode: Optional[str] = None) -> dict[str, Any]: + """启动前按实例模式钳制 body(模拟/实盘同路径).""" + m = mode or get_hedge_po_mode() + out = dict(body or {}) + opt_u = option_underlying(m) + perp_u = perp_underlying(m) + out["po_mode"] = m + out["underlying"] = opt_u + out["option_underlying"] = opt_u + out["perp_underlying"] = perp_u + # 以期权为主 + 组合盈利金额出场 + out["option_primary"] = True + out["exit_mode"] = "profit" + ratio = default_option_perp_ratio(m) + # 允许用户略调比例,但跨币默认锁到 1/k;若未填则写入默认 + if out.get("option_perp_ratio") in (None, ""): + out["option_perp_ratio"] = ratio + elif m == MODE_BTC_ETH: + # 跨币强制名义系数,避免页内误改导致与子账户策略不一致 + out["option_perp_ratio"] = ratio + # 永续 symbol:若未传或与模式不符则按 perp 标的重建 + sym = str(out.get("exchange_symbol") or "").upper() + want_prefix = f"{perp_u}/" + if not sym or not sym.startswith(want_prefix): + out["exchange_symbol"] = f"{perp_u}/USDT:USDT" + return out + + +def validate_body_matches_mode(body: dict[str, Any], *, mode: Optional[str] = None) -> Optional[str]: + m = mode or get_hedge_po_mode() + opt_u = option_underlying(m) + perp_u = perp_underlying(m) + uly = str(body.get("underlying") or body.get("option_underlying") or "").upper() + if uly and uly != opt_u: + return f"本实例模式 {mode_label(m)} 期权标的须为 {opt_u},收到 {uly}" + inst = str(body.get("opt_inst_id") or "").upper() + if inst and not inst.startswith(f"{opt_u}-"): + return f"期权合约须为 {opt_u} 标的(本实例 {mode_label(m)})" + sym = str(body.get("exchange_symbol") or "").upper() + if sym and not sym.startswith(f"{perp_u}/"): + return f"永续合约须为 {perp_u}(本实例 {mode_label(m)})" + return None + + +def combo_net_usdt( + net: dict[str, Any] | None, + *, + option_index: float, +) -> Optional[float]: + """币本位期权净利×期权指数 + 永续 U 净利 → 组合 U. + + 监控出场与持仓卡必须同口径;禁止把币与 U 直接相加. + """ + if not net: + return None + idx = float(option_index or 0) + if idx <= 0: + return None + opt_coin = float(net.get("opt_net") or 0) + perp_u = float(net.get("perp_net") or 0) + return round(opt_coin * idx + perp_u, 4) + + +def size_cross_notional( + *, + option_coin_qty: float, + option_index: float, + perp_index: float, + contract_size: float, + notional_k: Optional[float] = None, +) -> dict[str, Any]: + """跨币/同币统一:期权币名义 → U 名义 → 永续张数. + + 同币时 option_index≈perp_index,等价于 coin_qty * k / cs. + """ + k = float(notional_k if notional_k is not None else cross_notional_k()) + oc = float(option_coin_qty or 0) + oi = float(option_index or 0) + pi = float(perp_index or 0) + cs = float(contract_size or 0.01) + if oc <= 0 or oi <= 0 or pi <= 0 or cs <= 0 or k <= 0: + return { + "ok": False, + "msg": "跨币定仓参数无效", + "option_notional_usdt": 0.0, + "perp_notional_usdt": 0.0, + "perp_coin": 0.0, + "contracts": 0.0, + "notional_k": k, + } + opt_n = oc * oi + perp_n = opt_n * k + perp_coin = perp_n / pi + contracts = perp_coin / cs + return { + "ok": True, + "msg": "", + "option_notional_usdt": round(opt_n, 4), + "perp_notional_usdt": round(perp_n, 4), + "perp_coin": round(perp_coin, 8), + "contracts": contracts, + "notional_k": k, + } + + +def public_mode_payload(mode: Optional[str] = None) -> dict[str, Any]: + m = mode or get_hedge_po_mode() + return { + "po_mode": m, + "po_mode_label": mode_label(m), + "po_mode_short": mode_short_label(m), + "option_underlying": option_underlying(m), + "perp_underlying": perp_underlying(m), + "cross_asset": is_cross_asset(m), + "cross_notional_k": cross_notional_k() if is_cross_asset(m) else None, + "option_perp_ratio": default_option_perp_ratio(m), + "exit_mode": "profit", + "option_primary": True, + "instance_rule": "一实例一种模式对应一个子账户;模拟盘与实盘同路径;账户互不干涉", + } diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index e5df9d8..a779f16 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -1,5 +1,12 @@
+ {{ hedge_po_mode_label | default('ETH永期') }}
{# 模式由导航/env 决定,页内不再显示「永期对冲」Tab #} @@ -46,19 +54,22 @@
规则说明
-

手动选约:左侧选 Call/Put 与到期,点「选择」弹窗下单。买 Call → 永续空;买 Put → 永续多。比例默认 2(期权名义÷比例=永续名义)。

+

实例模式:HEDGE_PO_MODE={{ hedge_po_mode | default('eth') }}({{ hedge_po_mode_label | default('ETH永期') }})。一实例一种模式对应一个子账户;模拟盘与实盘同一执行路径;账户互不干涉。不可页内切换。

+

腿组合:期权 {{ hedge_option_underlying or 'ETH' }} + 永续 {{ hedge_perp_underlying or 'ETH' }}{% if hedge_cross_asset %};跨币名义系数 K={{ hedge_cross_notional_k or 0.5 }}(永续名义=期权名义×K){% endif %}。买 Call → 永续空;买 Put → 永续多。比例默认 {{ hedge_option_perp_ratio | default(2) }}。

手动下单:开仓按卖一限价(非交易所市价)。先开期权(按张数买币并+5%滑点),再开永续。永续杠杆取 env:BTC=BTC_LEVERAGE/ETH=ALT_LEVERAGE。

-

出场:盈利金额(扣开平双边手续费后净利)或目标位点数二选一。平仓先期权(须买一流动性)再永续,再把币全部卖回 USDT。一账户同时仅一笔对冲仓。

-

开仓校验:已选合约 · 张数≥1 · 出场条件已填 · 门禁「开仓:可」· 账户有足够交易 USDT。

+

出场:仅组合盈利金额(U)(期权净利×期权指数 + 永续净利,扣开平双边手续费)。平仓先期权(须买一流动性)再永续,再把期权标的币卖回 USDT。一账户同时仅一笔对冲仓。

+

开仓校验:已选合约 · 张数≥1 · 盈利目标已填 · 门禁「开仓:可」· 账户有足够交易 USDT。

期权链:默认显示约 5 档(平值 + 实值2 + 虚值2);勾选「展开全部」查看该到期更多行权价。

账户:期权币本位 · 交易/资金计 USDT · 永续全仓 / 期权逐仓(余额见下方划转)。

-

组合净盈亏:扣开平双边手续费;永续按平仓方向盘口均价估值。

+

组合净盈亏:扣开平双边手续费;永续按平仓方向盘口均价估值;一律折 U。

买 Call + 永续空 · 点「选择」弹窗下单。

- - + + + + diff --git a/scripts/audit_hedge_po_mode.py b/scripts/audit_hedge_po_mode.py new file mode 100644 index 0000000..cb279ae --- /dev/null +++ b/scripts/audit_hedge_po_mode.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""审计永期实例模式配置与纯函数口径(不连交易所下单). + +用法: + python scripts/audit_hedge_po_mode.py + HEDGE_PO_MODE=btc_eth python scripts/audit_hedge_po_mode.py +""" +from __future__ import annotations + +import os +import sys + +# 仓库根 +_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + + +def main() -> int: + from lib.hedge_plan.hedge_po_mode_lib import ( + apply_mode_to_start_body, + clamp_hedge_po_mode_on_startup, + combo_net_usdt, + public_mode_payload, + size_cross_notional, + validate_body_matches_mode, + ) + + print("=== HEDGE_PO_MODE 审计 ===") + summary = clamp_hedge_po_mode_on_startup() + payload = public_mode_payload(summary["mode"]) + ok = True + + def check(name: str, cond: bool, detail: str = "") -> None: + nonlocal ok + mark = "PASS" if cond else "FAIL" + if not cond: + ok = False + print(f"[{mark}] {name}" + (f" — {detail}" if detail else "")) + + check("启动钳制", summary.get("ok") is True, str(summary.get("mode"))) + check("期权/永续标的已填", bool(payload.get("option_underlying") and payload.get("perp_underlying"))) + check("出场强制 profit", payload.get("exit_mode") == "profit") + check("以期权为主", payload.get("option_primary") is True) + check("实例规则文案", "子账户" in str(payload.get("instance_rule") or "")) + + if payload.get("po_mode") == "btc_eth": + check("跨币期权=BTC", payload.get("option_underlying") == "BTC") + check("跨币永续=ETH", payload.get("perp_underlying") == "ETH") + k = float(payload.get("cross_notional_k") or 0) + check("K 在合理区间", 0.05 <= k <= 5.0, f"k={k}") + sz = size_cross_notional( + option_coin_qty=0.1, + option_index=100_000, + perp_index=2_500, + contract_size=0.01, + notional_k=k, + ) + check("跨币定仓样例", bool(sz.get("ok")), str(sz.get("contracts"))) + expect = 0.1 * 100_000 * k / 2_500 / 0.01 + check("定仓≈名义公式", abs(float(sz["contracts"]) - expect) < 1e-6, f"{sz['contracts']} vs {expect}") + + body = apply_mode_to_start_body( + { + "direction": "long", + "opt_inst_id": f"{payload['option_underlying']}-USD-260831-1000-C", + "exchange_symbol": f"{payload['perp_underlying']}/USDT:USDT", + "profit_target_usdt": 10, + "sheets": 1, + "contracts": 1, + "option_perp_ratio": payload.get("option_perp_ratio"), + } + ) + check("body 钳制后校验通过", validate_body_matches_mode(body) is None) + + # 组合 U:币*指数 + 永续U + fake_net = {"opt_net": 0.01, "perp_net": 5.0, "net": 5.01} + u = combo_net_usdt(fake_net, option_index=100_000) + check("组合U不混单位", u == 1005.0, f"got {u}") + + if summary.get("warnings"): + print("--- warnings ---") + for w in summary["warnings"]: + print(" ", w) + + print("===", "全部通过" if ok else "存在失败", "===") + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_hedge_plan_option_primary.py b/tests/test_hedge_plan_option_primary.py index efaba0b..f4e604a 100644 --- a/tests/test_hedge_plan_option_primary.py +++ b/tests/test_hedge_plan_option_primary.py @@ -1,5 +1,7 @@ """永期「以期权为主」定仓/方向/目标位/校验.""" +import os import unittest +from unittest.mock import patch from lib.hedge_plan.hedge_plan_option_primary_lib import ( PREMIUM_EXEC_FACTOR, @@ -114,6 +116,7 @@ class TestOptionPrimary(unittest.TestCase): body = { "option_primary": True, "watch_entry": 0, + "market_order": 1, "direction": "long", "contracts": 1, "opt_inst_id": "ETH-USD-260831-1900-C", @@ -121,6 +124,8 @@ class TestOptionPrimary(unittest.TestCase): "sheets": 2, "exchange_symbol": "ETH/USDT:USDT", "premium_budget": 100, + "exit_mode": "profit", + "profit_target_usdt": 20, "option_target_points": 50, "perp_target_points": 30, "option_perp_ratio": 2, @@ -132,11 +137,16 @@ class TestOptionPrimary(unittest.TestCase): "min_option_hours": 36, "hours_to_expiry": 40, "option_leverage": 100, + "underlying": "ETH", } 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)) + with patch.dict(os.environ, {"HEDGE_PO_MODE": "eth"}, clear=False): + from lib.hedge_plan.hedge_po_mode_lib import clamp_hedge_po_mode_on_startup + + clamp_hedge_po_mode_on_startup() + self.assertIsNone(validate_start_body("perp_options", body)) + bad = dict(body, opt_type="P") + self.assertIsNotNone(validate_start_body("perp_options", bad)) def test_pick_candidate_respects_leverage(self): chain = { diff --git a/tests/test_hedge_po_mode.py b/tests/test_hedge_po_mode.py new file mode 100644 index 0000000..b85d879 --- /dev/null +++ b/tests/test_hedge_po_mode.py @@ -0,0 +1,177 @@ +"""永期实例模式 HEDGE_PO_MODE:钳制/定仓/校验/组合U口径.""" +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from lib.hedge_plan.hedge_plan_option_primary_lib import ( + contracts_from_option_sheets, + estimate_combo_net_pnl, + size_from_premium, +) +from lib.hedge_plan.hedge_po_mode_lib import ( + MODE_BTC, + MODE_BTC_ETH, + MODE_ETH, + apply_mode_to_start_body, + clamp_hedge_po_mode_on_startup, + combo_net_usdt, + default_option_perp_ratio, + get_hedge_po_mode, + normalize_hedge_po_mode, + option_underlying, + perp_underlying, + public_mode_payload, + size_cross_notional, + validate_body_matches_mode, +) + + +class TestHedgePoMode(unittest.TestCase): + def test_normalize_aliases(self): + self.assertEqual(normalize_hedge_po_mode("ETH"), MODE_ETH) + self.assertEqual(normalize_hedge_po_mode("btc-eth"), MODE_BTC_ETH) + self.assertEqual(normalize_hedge_po_mode("跨币"), MODE_BTC_ETH) + self.assertEqual(normalize_hedge_po_mode("nope"), "") + + def test_underlyings(self): + self.assertEqual(option_underlying(MODE_ETH), "ETH") + self.assertEqual(perp_underlying(MODE_ETH), "ETH") + self.assertEqual(option_underlying(MODE_BTC), "BTC") + self.assertEqual(perp_underlying(MODE_BTC), "BTC") + self.assertEqual(option_underlying(MODE_BTC_ETH), "BTC") + self.assertEqual(perp_underlying(MODE_BTC_ETH), "ETH") + + def test_cross_ratio_from_k(self): + with patch.dict(os.environ, {"HEDGE_CROSS_NOTIONAL_K": "0.5"}, clear=False): + self.assertAlmostEqual(default_option_perp_ratio(MODE_BTC_ETH), 2.0) + + def test_clamp_startup_and_sync_underly(self): + with patch.dict( + os.environ, + {"HEDGE_PO_MODE": "btc_eth", "OKX_OPTIONS_DEFAULT_UNDERLY": "ETH"}, + clear=False, + ): + summary = clamp_hedge_po_mode_on_startup() + self.assertEqual(summary["mode"], MODE_BTC_ETH) + self.assertEqual(os.environ["HEDGE_PO_MODE"], MODE_BTC_ETH) + self.assertEqual(os.environ["OKX_OPTIONS_DEFAULT_UNDERLY"], "BTC") + self.assertEqual(get_hedge_po_mode(), MODE_BTC_ETH) + + def test_clamp_invalid_falls_back(self): + with patch.dict( + os.environ, + {"HEDGE_PO_MODE": "weird", "OKX_OPTIONS_DEFAULT_UNDERLY": "BTC"}, + clear=False, + ): + summary = clamp_hedge_po_mode_on_startup() + self.assertEqual(summary["mode"], MODE_BTC) + self.assertTrue(summary["warnings"]) + + def test_apply_and_validate_body(self): + with patch.dict(os.environ, {"HEDGE_PO_MODE": "btc_eth", "HEDGE_CROSS_NOTIONAL_K": "0.5"}): + clamp_hedge_po_mode_on_startup() + body = apply_mode_to_start_body( + { + "direction": "long", + "underlying": "ETH", + "opt_inst_id": "BTC-USD-260831-90000-C", + "exchange_symbol": "ETH/USDT:USDT", + "profit_target_usdt": 20, + } + ) + self.assertEqual(body["underlying"], "BTC") + self.assertEqual(body["perp_underlying"], "ETH") + self.assertEqual(body["exit_mode"], "profit") + self.assertTrue(body["option_primary"]) + self.assertAlmostEqual(float(body["option_perp_ratio"]), 2.0) + self.assertIsNone(validate_body_matches_mode(body)) + bad = dict(body, opt_inst_id="ETH-USD-260831-2000-C") + self.assertIn("BTC", validate_body_matches_mode(bad) or "") + + def test_size_cross_notional_half(self): + # 0.1 BTC @ 100k → 10k U 期权名义; k=0.5 → 5k ETH 名义 @ 2500 → 2 ETH → 200 张(cs=0.01) + sz = size_cross_notional( + option_coin_qty=0.1, + option_index=100_000, + perp_index=2500, + contract_size=0.01, + notional_k=0.5, + ) + self.assertTrue(sz["ok"]) + self.assertAlmostEqual(sz["option_notional_usdt"], 10_000.0) + self.assertAlmostEqual(sz["perp_notional_usdt"], 5_000.0) + self.assertAlmostEqual(sz["perp_coin"], 2.0) + self.assertAlmostEqual(sz["contracts"], 200.0) + + def test_size_from_premium_cross_matches_notional(self): + # ask=0.01 BTC → 预算 1 BTC 权利金(币); usable 0.95 → coin=0.95 → sheets + # 用整张回写后再跨币 + sized = size_from_premium( + premium_budget=1000, # 若 ask 用币报价则预算单位需一致;此处用大预算保证 ≥1 张 + ask=0.01, + ct_mult=0.01, + ratio=2, + contract_size=0.01, + option_index=100_000, + perp_index=2_500, + ) + self.assertTrue(sized["ok"], sized.get("msg")) + self.assertTrue(sized.get("cross_asset") or sized.get("option_notional_usdt")) + # coin_qty = sheets * 0.01; 名义路径 + self.assertGreater(float(sized["contracts"]), 0) + + def test_contracts_from_sheets_same_vs_cross(self): + same = contracts_from_option_sheets( + sheets=10, ct_mult=0.01, ratio=2, contract_size=0.01 + ) + self.assertTrue(same["ok"]) + # 0.1 币 / 2 / 0.01 = 5 张 + self.assertAlmostEqual(same["contracts"], 5.0) + + cross = contracts_from_option_sheets( + sheets=10, + ct_mult=0.01, + ratio=2, + contract_size=0.01, + option_index=100_000, + perp_index=2_500, + ) + self.assertTrue(cross["ok"]) + # 0.1 BTC * 100k * 0.5 / 2500 / 0.01 = 200 + self.assertAlmostEqual(cross["contracts"], 200.0) + + def test_combo_net_usdt_not_mix_units(self): + net = estimate_combo_net_pnl( + view_side="long", + strike=90000, + index_px=91000, + ask_open=0.01, + bid=0.012, + sheets=1, + ct_mult=0.01, + perp_direction="short", + perp_entry=2500, + perp_mark=2450, + contracts=10, + contract_size=0.01, + fee=0.0, + option_fee=0.0, + ) + # opt_net 为币;perp_net 为 U;禁止直接用 net['net'] 出门控 + u = combo_net_usdt(net, option_index=91000) + self.assertIsNotNone(u) + self.assertAlmostEqual(u, float(net["opt_net"]) * 91000 + float(net["perp_net"]), places=4) + + def test_public_payload(self): + with patch.dict(os.environ, {"HEDGE_PO_MODE": "eth"}): + clamp_hedge_po_mode_on_startup() + p = public_mode_payload() + self.assertEqual(p["po_mode"], MODE_ETH) + self.assertEqual(p["exit_mode"], "profit") + self.assertIn("子账户", p["instance_rule"]) + + +if __name__ == "__main__": + unittest.main()