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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-14 20:05:25 +08:00
parent ab7a2309ad
commit 5d161b4220
18 changed files with 1215 additions and 115 deletions
+5
View File
@@ -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
+3
View File
@@ -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 权限与资金。
+13 -1
View File
@@ -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()
+137
View File
@@ -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)
+104 -33
View File
@@ -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 =
"期权 <strong>" +
fmt(sized.eth_qty, 2) +
"</strong> " +
coin +
optCoin +
" / " +
sized.sheets +
" 张 → 永续 <strong>" +
fmt(sized.perp_qty, 4) +
"</strong> " +
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)");
}
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();
+16
View File
@@ -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;
+7
View File
@@ -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", "实盘"),
+13 -1
View File
@@ -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",
+3
View File
@@ -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")
+29 -11
View File
@@ -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:
+124 -11
View File
@@ -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,
}
+78 -18
View File
@@ -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
+80 -22
View File
@@ -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,24 +678,33 @@ 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,
@@ -675,16 +712,17 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
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"
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")
+296
View File
@@ -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": "一实例一种模式对应一个子账户;模拟盘与实盘同路径;账户互不干涉",
}
+18 -7
View File
@@ -1,5 +1,12 @@
<div class="hedge-plan-page-wrap" style="grid-column:1/-1" id="hedge-plan-root"
data-default-underly="{{ options_default_underly | default('ETH') }}"
data-default-underly="{{ hedge_option_underlying or options_default_underly | default('ETH') }}"
data-po-mode="{{ hedge_po_mode | default('eth') }}"
data-po-mode-label="{{ hedge_po_mode_label | default('ETH永期') }}"
data-option-underly="{{ hedge_option_underlying or options_default_underly | default('ETH') }}"
data-perp-underly="{{ hedge_perp_underlying or options_default_underly | default('ETH') }}"
data-cross-asset="{{ '1' if hedge_cross_asset else '0' }}"
data-cross-k="{{ hedge_cross_notional_k if hedge_cross_notional_k is not none else '' }}"
data-option-perp-ratio="{{ hedge_option_perp_ratio | default(2) }}"
data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}"
data-options-enabled="{{ '1' if options_enabled else '0' }}"
data-show-perp="{{ '1' if hedge_plan_show_perp_options else '0' }}"
@@ -24,6 +31,7 @@
{# 顶栏「对冲计划」已改为导航「永期/期期对冲」;页内不再重复大标题 #}
<div class="hp-page-toolbar">
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
<span class="hp-po-mode-badge" id="hp-po-mode-badge" title="一实例一种模式对应一个子账户;模拟与实盘同路径">{{ hedge_po_mode_label | default('ETH永期') }}</span>
<span class="muted hp-po-gate-inline" id="hp-po-gate-inline" aria-live="polite"></span>
</div>
{# 模式由导航/env 决定,页内不再显示「永期对冲」Tab #}
@@ -46,19 +54,22 @@
<details class="tip-collapse hp-rule-collapse">
<summary class="tip-collapse-summary">规则说明</summary>
<div class="tip-collapse-body rule-tip">
<p><strong>手动选约</strong>:左侧选 Call/Put 与到期,点「选择」弹窗下单。买 Call → 永续空;买 Put → 永续多。比例默认 <strong>2</strong>(期权名义÷比例=永续名义)。</p>
<p><strong>实例模式</strong>:<code>HEDGE_PO_MODE</code>=<strong>{{ hedge_po_mode | default('eth') }}</strong>({{ hedge_po_mode_label | default('ETH永期') }})。一实例一种模式对应一个子账户;模拟盘与实盘同一执行路径;账户互不干涉。不可页内切换。</p>
<p><strong>腿组合</strong>:期权 <strong>{{ hedge_option_underlying or 'ETH' }}</strong> + 永续 <strong>{{ hedge_perp_underlying or 'ETH' }}</strong>{% if hedge_cross_asset %};跨币名义系数 K=<strong>{{ hedge_cross_notional_k or 0.5 }}</strong>(永续名义=期权名义×K){% endif %}。买 Call → 永续空;买 Put → 永续多。比例默认 <strong>{{ hedge_option_perp_ratio | default(2) }}</strong>。</p>
<p><strong>手动下单</strong>:开仓按<strong>卖一限价</strong>(非交易所市价)。先开期权(按张数买币并+5%滑点),再开永续。永续杠杆取 env:BTC=<code>BTC_LEVERAGE</code>/ETH=<code>ALT_LEVERAGE</code>。</p>
<p><strong>出场</strong>:盈利金额(扣开平双边手续费后净利)或目标位点数二选一。平仓先期权(须买一流动性)再永续,再把币全部卖回 USDT。一账户同时仅一笔对冲仓。</p>
<p><strong>开仓校验</strong>:已选合约 · 张数≥1 · 出场条件已填 · 门禁「开仓:可」· 账户有足够交易 USDT。</p>
<p><strong>出场</strong>:仅<strong>组合盈利金额(U)</strong>(期权净利×期权指数 + 永续净利,扣开平双边手续费)。平仓先期权(须买一流动性)再永续,再把期权标的币卖回 USDT。一账户同时仅一笔对冲仓。</p>
<p><strong>开仓校验</strong>:已选合约 · 张数≥1 · 盈利目标已填 · 门禁「开仓:可」· 账户有足够交易 USDT。</p>
<p><strong>期权链</strong>:默认显示约 <strong>5</strong> 档(平值 + 实值2 + 虚值2);勾选「展开全部」查看该到期更多行权价。</p>
<p><strong>账户</strong>:期权币本位 · 交易/资金计 USDT · 永续全仓 / 期权逐仓(余额见下方划转)。</p>
<p><strong>组合净盈亏</strong>:扣开平双边手续费;永续按平仓方向盘口均价估值。</p>
<p><strong>组合净盈亏</strong>:扣开平双边手续费;永续按平仓方向盘口均价估值;一律折 U。</p>
<p class="muted" id="hp-po-pair-hint">买 Call + 永续空 · 点「选择」弹窗下单。</p>
</div>
</details>
<div class="form-row hp-opt-toolbar hp-po-opt-toolbar options-chain-toolbar">
<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>
<span class="hp-uly-lock" id="hp-uly-lock" title="由 HEDGE_PO_MODE 锁定,不可页内切换">
<button type="button" class="btn-secondary hp-uly-btn active" data-uly="ETH" disabled>ETH</button>
<button type="button" class="btn-secondary hp-uly-btn" data-uly="BTC" disabled>BTC</button>
</span>
<select id="hp-exp-select"><option value="">选择到期日</option></select>
<span 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="买 Call → 永续空"><span class="hp-oo-check" aria-hidden="true">✓</span>看涨 Call</button>
+91
View File
@@ -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())
+10
View File
@@ -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,8 +137,13 @@ class TestOptionPrimary(unittest.TestCase):
"min_option_hours": 36,
"hours_to_expiry": 40,
"option_leverage": 100,
"underlying": "ETH",
}
self.assertIsNone(validate_option_primary_start(body))
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))
+177
View File
@@ -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()