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
+106 -35
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)");
}
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();
+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
+86 -28
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,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")
+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>